{"slug": "show-hn-aisync-exits-3-when-two-ai-agents-claim-the-same-file", "title": "Show HN: Aisync exits 3 when two AI agents claim the same file", "summary": "Aisync, a collaboration layer for coordinating multiple AI coding agents, exits with status code 3 when two agents claim the same file, according to its Show HN submission and the collab.py source file in its repository. The tool maintains a shared rule set in preferences.md that any agent (Claude Code, Codex, Cursor) can update with one command, propagates those rules to every tool on the next cycle, and tracks file claims with a TTL that warns on overlaps. A read-only report function flags unattributed commits, silent tools, missing projects, stale blockers and failures without changing rules itself.", "body_md": "## Repository navigation\n\n## Expand file tree\n\n/\n\nCopy path\n# collab.py\n\nMore file actions\n\n476 lines (414 loc) · 24.6 KB\n\n/\n\nCopy path\n# collab.py\n\n## File metadata and controls\n\n476 lines (414 loc) · 24.6 KB\n\n1\n\n2\n\n3\n\n4\n\n5\n\n6\n\n7\n\n8\n\n9\n\n10\n\n11\n\n12\n\n13\n\n14\n\n15\n\n16\n\n17\n\n18\n\n19\n\n20\n\n21\n\n22\n\n23\n\n24\n\n25\n\n26\n\n27\n\n28\n\n29\n\n30\n\n31\n\n32\n\n33\n\n34\n\n35\n\n36\n\n37\n\n38\n\n39\n\n40\n\n41\n\n42\n\n43\n\n44\n\n45\n\n46\n\n47\n\n48\n\n49\n\n50\n\n51\n\n52\n\n53\n\n54\n\n55\n\n56\n\n57\n\n58\n\n59\n\n60\n\n61\n\n62\n\n63\n\n64\n\n65\n\n66\n\n67\n\n68\n\n69\n\n70\n\n71\n\n72\n\n73\n\n74\n\n75\n\n76\n\n77\n\n78\n\n79\n\n80\n\n81\n\n82\n\n83\n\n84\n\n85\n\n86\n\n87\n\n88\n\n89\n\n90\n\n91\n\n92\n\n93\n\n94\n\n95\n\n96\n\n97\n\n98\n\n99\n\n100\n\n101\n\n102\n\n103\n\n104\n\n105\n\n106\n\n107\n\n108\n\n109\n\n110\n\n111\n\n112\n\n113\n\n114\n\n115\n\n116\n\n117\n\n118\n\n119\n\n120\n\n121\n\n122\n\n123\n\n124\n\n125\n\n126\n\n127\n\n128\n\n129\n\n130\n\n131\n\n132\n\n133\n\n134\n\n135\n\n136\n\n137\n\n138\n\n139\n\n140\n\n141\n\n142\n\n143\n\n144\n\n145\n\n146\n\n147\n\n148\n\n149\n\n150\n\n151\n\n152\n\n153\n\n154\n\n155\n\n156\n\n157\n\n158\n\n159\n\n160\n\n161\n\n162\n\n163\n\n164\n\n165\n\n166\n\n167\n\n168\n\n169\n\n170\n\n171\n\n172\n\n173\n\n174\n\n175\n\n176\n\n177\n\n178\n\n179\n\n180\n\n181\n\n182\n\n183\n\n184\n\n185\n\n186\n\n187\n\n188\n\n189\n\n190\n\n191\n\n192\n\n193\n\n194\n\n195\n\n196\n\n197\n\n198\n\n199\n\n200\n\n201\n\n202\n\n203\n\n204\n\n205\n\n206\n\n207\n\n208\n\n209\n\n210\n\n211\n\n212\n\n213\n\n214\n\n215\n\n216\n\n217\n\n218\n\n219\n\n220\n\n221\n\n222\n\n223\n\n224\n\n225\n\n226\n\n227\n\n228\n\n229\n\n230\n\n231\n\n232\n\n233\n\n234\n\n235\n\n236\n\n237\n\n238\n\n239\n\n240\n\n241\n\n242\n\n243\n\n244\n\n245\n\n246\n\n247\n\n248\n\n249\n\n250\n\n251\n\n252\n\n253\n\n254\n\n255\n\n256\n\n257\n\n258\n\n259\n\n260\n\n261\n\n262\n\n263\n\n264\n\n265\n\n266\n\n267\n\n268\n\n269\n\n270\n\n271\n\n272\n\n273\n\n274\n\n275\n\n276\n\n277\n\n278\n\n279\n\n280\n\n281\n\n282\n\n283\n\n284\n\n285\n\n286\n\n287\n\n288\n\n289\n\n290\n\n291\n\n292\n\n293\n\n294\n\n295\n\n296\n\n297\n\n298\n\n299\n\n300\n\n301\n\n302\n\n303\n\n304\n\n305\n\n306\n\n307\n\n308\n\n309\n\n310\n\n311\n\n312\n\n313\n\n314\n\n315\n\n316\n\n317\n\n318\n\n319\n\n320\n\n321\n\n322\n\n323\n\n324\n\n325\n\n326\n\n327\n\n328\n\n329\n\n330\n\n331\n\n332\n\n333\n\n334\n\n335\n\n336\n\n337\n\n338\n\n339\n\n340\n\n341\n\n342\n\n343\n\n344\n\n345\n\n346\n\n347\n\n348\n\n349\n\n350\n\n351\n\n352\n\n353\n\n354\n\n355\n\n356\n\n357\n\n358\n\n359\n\n360\n\n361\n\n362\n\n363\n\n364\n\n365\n\n366\n\n367\n\n368\n\n369\n\n370\n\n371\n\n372\n\n373\n\n374\n\n375\n\n376\n\n377\n\n378\n\n379\n\n380\n\n381\n\n382\n\n383\n\n384\n\n385\n\n386\n\n387\n\n388\n\n389\n\n390\n\n391\n\n392\n\n393\n\n394\n\n395\n\n396\n\n397\n\n398\n\n399\n\n400\n\n401\n\n402\n\n403\n\n404\n\n405\n\n406\n\n407\n\n408\n\n409\n\n410\n\n411\n\n412\n\n413\n\n414\n\n415\n\n416\n\n417\n\n418\n\n419\n\n420\n\n421\n\n422\n\n423\n\n424\n\n425\n\n426\n\n427\n\n428\n\n429\n\n430\n\n431\n\n432\n\n433\n\n434\n\n435\n\n436\n\n437\n\n438\n\n439\n\n440\n\n441\n\n442\n\n443\n\n444\n\n445\n\n446\n\n447\n\n448\n\n449\n\n450\n\n451\n\n452\n\n453\n\n454\n\n455\n\n456\n\n457\n\n458\n\n459\n\n460\n\n461\n\n462\n\n463\n\n464\n\n465\n\n466\n\n467\n\n468\n\n469\n\n470\n\n471\n\n472\n\n473\n\n474\n\n475\n\n476\n\n\"\"\"aisync collaboration layer: shared rules any agent can update, claims, progress fan-out, report.\n\nWhat: the pieces that make the hub the one core every agent follows.\n\n  - rule_add: any agent (Claude Code, Codex, Cursor, ...) adds a shared rule with one command; it\n\n    lands in preferences.md (the truth) and the next cycle copies it to every tool.\n\n  - harvest_rules: new timestamped entries in the \"## Meta rule\" section of the AGENTS.md of each\n\n    repo listed in `rule_sources` are imported automatically, so a rule an agent wrote there (and\n\n    forgot to `aisync rule add`) still reaches every tool.\n\n  - claims: \"I am working on these files of this repo now\", with a TTL; overlaps warn.\n\n  - render_progress: the rules pointer + live progress written into files each tool auto-loads\n\n    (e.g. a block in ~/.codex/AGENTS.md), so an agent sees it without being told to look.\n\n  - report: read-only self-check (unattributed commits, silent tools, missing projects, stale\n\n    blockers, failures). It only reports; it never changes a rule by itself.\n\nWhy:  one shared rule set that any agent can update and every agent obeys.\n\nHow:  every function here is called from core.run_cycle (isolated steps) or from the CLI.\n\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\n\nfrom datetime import datetime, timedelta\n\nfrom pathlib import Path\n\nfrom . import core, ruleids\n\nRULES_HEAD = \"### Rules added by agents (via aisync, newest last)\"\n\nMETA_HEAD = \"## Meta rule\"\n\n_ENTRY = re.compile(r\"^- (\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2} [A-Z]{2,5}):\\s*(.*)$\")\n\nCLAIM_TTL_MIN = 120\n\ndef _norm(text: str) -> str:\n\n return re.sub(r\"[^a-z0-9\\u4e00-\\u9fff]+\", \"\", text.lower())\n\ndef _stamp() -> str:\n\n return datetime.now().astimezone().strftime(\"%Y-%m-%d %H:%M %Z\")\n\ndef _tilde(p: Path | str) -> str:\n\n return str(p).replace(str(Path.home()), \"~\", 1)\n\n# ───────────────────────────── shared rules ─────────────────────────────\n\ndef rule_lines(block: str) -> list[str]:\n\n return [l for l in block.splitlines() if l.startswith(\"- \")]\n\ndef _insert_rule(cfg: core.Config, bullet: str, key_text: str) -> str:\n\n \"\"\"Append one bullet, with the next free ID, inside the owner-preferences block.\n\n    Returns the bullet as written, or \"\" if an equal rule exists.\"\"\"\n\n path = cfg.root / \"preferences.md\"\n\n text = path.read_text()\n\n block = core.extract_block(text)\n\n if block is None:\n\n raise ValueError(\"preferences.md has no owner-preferences block\")\n\n key = _norm(key_text)[:60]\n\n if key and any(key in _norm(l) for l in rule_lines(block)):\n\n return \"\"\n\n bullet = ruleids.with_id(bullet, ruleids.next_id(cfg))\n\n body = block[: -len(core.BLOCK_END)].rstrip(\"\\n\")\n\n if RULES_HEAD not in body:\n\n body += f\"\\n\\n{RULES_HEAD}\\n\"\n\n new_block = body + \"\\n\" + bullet + \"\\n\" + core.BLOCK_END\n\n core.atomic_write(path, core.replace_block(text, new_block))\n\n return bullet\n\nSUPERSEDED_HEAD = \"### 已取代\"\n\ndef topics(block: str) -> list[str]:\n\n \"\"\"Topic headings of a tidied rules block (### headings other than 已取代 and the legacy list).\"\"\"\n\n return [l[4:].strip() for l in block.splitlines()\n\n if l.startswith(\"### \") and l.strip() != SUPERSEDED_HEAD and l.strip() != RULES_HEAD]\n\ndef _sentences(text: str) -> int:\n\n \"\"\"Sentence ends are . ! ? followed by a space or the end, and 。！？ anywhere. A dot inside a file name or date\n\n    (x.env, x_autopilot.py, 2026-10-27) and semicolons do not end a sentence (2026-10-27: three false rejections).\"\"\"\n\n return len([x for x in re.split(r\"(?<=[.!?])(?=\\s|$)\\s*|(?<=[。！？])\\s*\", text) if x.strip()])\n\ndef _insert_topic_rule(cfg: core.Config, topic: str, bullet: str, key_text: str, replaces: str, new_name: str) -> dict:\n\n \"\"\"Put a rule at the top of its topic with the next free ID; the rule it replaces (given by ID like R-087,\n\n    or by a piece of its text) moves to rules-archive.md with its retired date and the new rule's ID.\"\"\"\n\n path = cfg.root / \"preferences.md\"\n\n text = path.read_text()\n\n block = core.extract_block(text)\n\n if block is None:\n\n raise ValueError(\"preferences.md has no owner-preferences block\")\n\n names = topics(block)\n\n hit = [t for t in names if topic.lower() in t.lower()]\n\n if len(hit) != 1:\n\n raise ValueError(f\"--topic must match exactly one topic; topics: {' | '.join(names)}\")\n\n lines = block.splitlines()\n\n span = None\n\n if replaces:\n\n rid = ruleids.is_id(replaces)\n\n cur = [i for i, l in enumerate(lines) if l.startswith(\"- \") and not _in_superseded(lines, i)\n\n and (ruleids.bullet_id(l) == rid if rid else replaces in l)]\n\n if len(cur) != 1:\n\n what = f\"rule {rid}\" if rid else \"current rule\"\n\n raise ValueError(f\"--replaces must match exactly one {what} (matched {len(cur)})\")\n\n i = cur[0]\n\n end = i + 1\n\n while end < len(lines) and lines[end].startswith(\"  \") and lines[end].strip():\n\n end += 1\n\n span = (i, end)\n\n # Duplicate check: only current rules count, minus the one being replaced. A revision usually\n\n # keeps the old rule's opening words, and 已取代 lines are history, not rules.\n\n key = _norm(key_text)[:60]\n\n live = [l for k, l in enumerate(lines) if l.startswith(\"- \") and not _in_superseded(lines, k)\n\n and not (span and span[0] <= k < span[1])]\n\n if key and any(key in _norm(l) for l in live):\n\n return {\"added\": False, \"moved\": None}\n\n new_id = ruleids.next_id(cfg)\n\n bullet = ruleids.with_id(bullet, new_id)\n\n moved = None\n\n if span:\n\n i, end = span\n\n old = \" \".join(x.strip() for x in lines[i:end])\n\n if not ruleids.bullet_id(old):              # a rule written before IDs existed gets one on the way out\n\n old = ruleids.with_id(old, ruleids.next_id(cfg))\n\n day = f\"{datetime.now().astimezone():%Y-%m-%d}\"\n\n moved = ruleids.archive_line(old, day, new_id, note=f\" 被 {new_id}「{new_name}」取代（{day}）\")\n\n del lines[i:end]\n\n h = lines.index(f\"### {hit[0]}\")\n\n lines.insert(h + 1, bullet)\n\n if moved:\n\n ruleids.archive_add(cfg, [moved])           # history first: a crash in between never loses the old text\n\n core.atomic_write(path, core.replace_block(text, \"\\n\".join(lines)))\n\n return {\"added\": True, \"moved\": moved, \"id\": new_id, \"bullet\": bullet}\n\ndef _in_superseded(lines: list[str], i: int) -> bool:\n\n for l in reversed(lines[:i]):\n\n if l.startswith(\"### \"):\n\n return l.strip() == SUPERSEDED_HEAD\n\n return False\n\ndef rule_add(cfg: core.Config, *, agent: str, text: str, reason: str = \"\", project: str = \"\",\n\n topic: str = \"\", replaces: str = \"\", enforced_by: str = \"\", judgment_only: str = \"\",\n\n planned: str = \"\", size: str = \"\") -> dict:\n\n \"\"\"Add a shared rule.\n\n    Once preferences.md is grouped into topics, the writing standard applies:\n\n    pick a topic, write only the current rule in at most three sentences, end with the source tag,\n\n    and move the rule it replaces (by ID or text) into rules-archive.md in the same write. Every new rule\n\n    gets the next free ID. The reason goes to the journal only.\n\n    Before the tidy (no topic headings) it appends to the legacy list as before.\n\n    A new rule may name what enforces it (enforced_by paths, a judgment_only reason, or the planned check that is\n\n    not written yet; one of them is required with `require_enforcer = true`); it is written to\n\n    rules-enforcement.toml under the new ID, never into the rules block.\n\n    \"\"\"\n\n from . import enforcement\n\n agent, text = (agent or \"\").strip(), \" \".join((text or \"\").split())\n\n if not agent:\n\n raise ValueError(\"agent is required (pass --agent or set AISYNC_AGENT)\")\n\n if len(text) < 8:\n\n raise ValueError(\"rule text is too short\")\n\n entry = (enforcement.check_args(enforced_by, judgment_only, planned, size)\n\n if cfg.require_enforcer or enforced_by or judgment_only or planned else None)   # optional unless require_enforcer\n\n old_id = ruleids.is_id(replaces) if replaces else None\n\n old_plan = enforcement.load(cfg).get(old_id, {}) if old_id else {}\n\n if old_plan.get(\"planned\") and (entry or {}).get(\"judgment_only\"):   # a replacement must not drop a planned check\n\n raise ValueError(f\"{old_id} has a planned check ({str(old_plan['planned'])[:80]}); the rule that replaces it \"\n\n \"keeps it: pass --planned \\\"<the check>\\\" --size S|M|L, or --enforced-by <path>\")\n\n if (old_plan.get(\"planned\") and (entry or {}).get(\"planned\")   # nor postpone it: a larger size moves the due day\n\n and enforcement.SIZES[entry[\"size\"]] > enforcement.SIZES.get(str(old_plan.get(\"size\", \"\")), 0)):\n\n raise ValueError(f\"{old_id} is planned {old_plan.get('size', '?')} since {old_plan.get('since', '?')}; a rule that \"\n\n f\"replaces it keeps that size (pass --size {old_plan.get('size', 'S')}). To move the due day \"\n\n \"afterwards: aisync rule enforce <new id> --planned \\\"<the check>\\\" --size <larger> --reason \\\"<why>\\\"\")\n\n who = agent + (f\", {project}\" if project else \"\")\n\n block = core.extract_block((cfg.root / \"preferences.md\").read_text()) or \"\"\n\n if topics(block):\n\n if not topic:\n\n raise ValueError(f\"--topic is required; topics: {' | '.join(topics(block))}\")\n\n if _sentences(text) > 3 and not cfg.rule_spec:\n\n raise ValueError(\"a rule is at most three sentences; put the why in --reason\")\n\n if cfg.rule_spec:\n\n from . import rulespec\n\n bad = rulespec.problems(text, need_id=False, need_tag=False)\n\n if bad:\n\n raise ValueError(\"rule is not an interface (\" + \"; \".join(bad) + \"); write \"\n\n \"'When: <trigger>. Do: <action>. Not: <forbidden>.' or pass --when/--do/--not\")\n\n bullet = f\"- {text.rstrip()} ({datetime.now().astimezone():%Y-%m-%d} · {who})\"\n\n with core.locked(cfg):\n\n rep = _insert_topic_rule(cfg, topic, bullet, text, replaces, text[:30])\n\n added = rep[\"added\"]\n\n bullet = rep.get(\"bullet\", bullet)\n\n else:\n\n bullet = f\"- {_stamp()} ({who}): {text}\" + (f\" Reason: {' '.join(reason.split())}\" if reason.strip() else \"\")\n\n with core.locked(cfg):\n\n written = _insert_rule(cfg, bullet, text)\n\n added, bullet, rep = bool(written), written or bullet, {\"moved\": None}\n\n if added and entry is not None:\n\n with core.locked(cfg):\n\n moved_id = ruleids.bullet_id(rep[\"moved\"]) if rep.get(\"moved\") else None\n\n was = enforcement.load(cfg).get(moved_id, {}) if moved_id else {}\n\n if was.get(\"planned\") and entry.get(\"planned\"):      # the clock of a replaced rule keeps running\n\n entry[\"since\"] = min(str(entry[\"since\"]), str(was.get(\"since\") or entry[\"since\"]))\n\n if enforcement.SIZES[entry[\"size\"]] > enforcement.SIZES.get(str(was.get(\"size\", \"\")), 1 << 30):\n\n entry[\"size\"] = was[\"size\"]              # replaced by a piece of its text: the size cannot grow here\n\n elif was.get(\"planned\") and entry.get(\"judgment_only\"):   # replaced by a piece of its text: leave a trace\n\n entry[\"note\"] = (f\"replaces {moved_id}, which had a planned check \"\n\n f\"({was.get('size', '?')}, since {was.get('since', '?')}): {was['planned']}\")\n\n enforcement.put(cfg, ruleids.bullet_id(bullet), entry)\n\n if added:\n\n why = f\" Reason: {' '.join(reason.split())}\" if reason.strip() and topics(block) else \"\"\n\n core.append_event(cfg, agent=agent, kind=\"rule_change\", project=project,\n\n summary=f\"shared rule added: [{ruleids.bullet_id(bullet)}] {text}{why}\"\n\n + (f\" (replaces [{ruleids.bullet_id(rep['moved'])}])\" if rep.get(\"moved\") else \"\"),\n\n files=[\"preferences.md\"] + ([ruleids.ARCHIVE] if rep.get(\"moved\") else []))\n\n return {\"added\": added, \"rule\": bullet, \"moved\": rep.get(\"moved\"), \"id\": ruleids.bullet_id(bullet) if added else None}\n\ndef rule_list(cfg: core.Config, archived: bool = False) -> list[str]:\n\n \"\"\"Current rules (each starts with its ID), or the retired ones from rules-archive.md.\"\"\"\n\n if archived:\n\n return ruleids.archive_entries(cfg)\n\n block = core.extract_block((cfg.root / \"preferences.md\").read_text()) or \"\"\n\n return rule_lines(block)\n\n# ───────────────────────────── rules written in repo AGENTS.md ─────────────────────────────\n\ndef rule_source_files(cfg: core.Config) -> list[Path]:\n\n \"\"\"AGENTS.md of every checkout/worktree of each rule-source repo (main checkout + *.wt-*).\"\"\"\n\n out = []\n\n for proj in core.project_list(cfg):\n\n for repo in cfg.rule_sources:\n\n if proj.name == repo or proj.name.startswith(repo + \".\"):\n\n if (proj / \"AGENTS.md\").exists():\n\n out.append(proj / \"AGENTS.md\")\n\n return out\n\ndef parse_meta_entries(text: str) -> list[tuple[str, str]]:\n\n \"\"\"(timestamp, full text) for each `- YYYY-MM-DD HH:MM TZ: ...` bullet under '## Meta rule'.\"\"\"\n\n entries, inside, cur = [], False, None\n\n for line in text.splitlines():\n\n if line.startswith(\"## \"):\n\n inside = line.startswith(META_HEAD)\n\n if cur:\n\n entries.append(cur)\n\n cur = None\n\n continue\n\n if not inside:\n\n continue\n\n m = _ENTRY.match(line)\n\n if m:\n\n if cur:\n\n entries.append(cur)\n\n cur = [m.group(1), m.group(2)]\n\n elif cur and line.startswith(\"  \") and line.strip():\n\n cur[1] += \" \" + line.strip()\n\n elif cur and not line.strip():\n\n entries.append(cur)\n\n cur = None\n\n if cur:\n\n entries.append(cur)\n\n return [(ts, \" \".join(t.split())) for ts, t in entries]\n\ndef _first_sentence(text: str, n: int = 220) -> str:\n\n m = re.match(r\"(.+?[.;。])(\\s|$)\", text)\n\n s = m.group(1) if m else text\n\n return s if len(s) <= n else s[: n - 1] + \"…\"\n\ndef harvest_rules(cfg: core.Config) -> dict:\n\n \"\"\"Import NEW meta-rule entries. A repo seen for the first time only records what exists.\"\"\"\n\n st = core.load_json(cfg.state / \"rule_sources.json\", {\"repos\": [], \"seen\": []})\n\n seen, repos = set(st[\"seen\"]), set(st[\"repos\"])\n\n rep = {\"files\": 0, \"imported\": [], \"errors\": []}\n\n first_sight = set()\n\n for path in rule_source_files(cfg):\n\n repo = path.parent.name.split(\".\")[0]\n\n try:\n\n entries = parse_meta_entries(path.read_text())\n\n except OSError as ex:\n\n rep[\"errors\"].append(f\"{path.parent.name}: {type(ex).__name__}\")\n\n continue\n\n rep[\"files\"] += 1\n\n if repo not in repos:\n\n first_sight.add(repo)\n\n for ts, text in entries:\n\n key = core._h(ts + _norm(text)[:40])\n\n if key in seen:\n\n continue\n\n seen.add(key)\n\n if repo in repos:          # an entry that appeared after we started watching\n\n summary = _first_sentence(text)\n\n main = path.parent.parent / repo # worktrees come and go; point at the main checkout\n\n src = main / \"AGENTS.md\" if (main / \"AGENTS.md\").exists() else path\n\n bullet = f\"- {ts} (from {repo} AGENTS.md): {summary} Full text: `{_tilde(src)}`.\"\n\n with core.locked(cfg):\n\n added = _insert_rule(cfg, bullet, summary)\n\n if added:                  # imports are never blocked; they count as none until someone says\n\n from . import enforcement\n\n enforcement.put(cfg, ruleids.bullet_id(added),\n\n                                        {\"none\": f\"imported from {repo} AGENTS.md without an enforcer\"})\n\n if added:\n\n core.append_event(cfg, agent=f\"repo:{repo}\", kind=\"rule_change\", project=repo,\n\n summary=f\"shared rule imported from {repo} AGENTS.md: {summary}\",\n\n files=[\"preferences.md\"])\n\n rep[\"imported\"].append(summary)\n\n repos |= first_sight\n\n core.save_json(cfg.state / \"rule_sources.json\", {\"repos\": sorted(repos), \"seen\": sorted(seen)})\n\n return rep\n\n# ───────────────────────────── claims (who is working where, now) ─────────────────────────────\n\ndef _now() -> datetime:\n\n return datetime.now().astimezone()\n\ndef load_claims(cfg: core.Config) -> list[dict]:\n\n now = _now()\n\n out = []\n\n for c in core.load_json(cfg.state / \"claims.json\", []):\n\n try:\n\n if datetime.fromisoformat(c[\"expires\"]) > now:\n\n out.append(c)\n\n except (KeyError, ValueError):\n\n continue\n\n return out\n\ndef _overlaps(a: dict, b: dict) -> bool:\n\n if a[\"project\"] != b[\"project\"] or a[\"agent\"] == b[\"agent\"]:\n\n return False\n\n fa, fb = set(a.get(\"files\") or []), set(b.get(\"files\") or [])\n\n if not fa or not fb:                         # a claim on the whole repo overlaps everything\n\n return True\n\n return bool(fa & fb) or any(x.rstrip(\"/\") + \"/\" in y or y.rstrip(\"/\") + \"/\" in x for x in fa for y in fb)\n\ndef find_overlaps(cfg: core.Config, agent: str, project: str, files) -> list[dict]:\n\n probe = {\"agent\": agent, \"project\": project, \"files\": list(files or [])}\n\n return [c for c in load_claims(cfg) if _overlaps(probe, c)]\n\ndef claim(cfg: core.Config, *, agent: str, project: str, summary: str, files=(), ttl_min: int = CLAIM_TTL_MIN) -> dict:\n\n if not (agent and project and summary):\n\n raise ValueError(\"claim needs --agent, --project and --summary\")\n\n files = [str(f) for f in files]\n\n with core.locked(cfg):\n\n claims = [c for c in load_claims(cfg) if not (c[\"agent\"] == agent and c[\"project\"] == project)]\n\n new = {\"agent\": agent, \"project\": project, \"files\": files, \"summary\": summary.strip(),\n\n \"since\": core.now_iso(), \"expires\": (_now() + timedelta(minutes=ttl_min)).isoformat(timespec=\"seconds\")}\n\n overlaps = [c for c in claims if _overlaps(new, c)]\n\n claims.append(new)\n\n core.save_json(cfg.state / \"claims.json\", claims)\n\n core.append_event(cfg, agent=agent, kind=\"claim\", project=project, summary=f\"claimed: {summary}\", files=files,\n\n refs={\"overlap_with\": \",\".join(sorted({c['agent'] for c in overlaps}))} if overlaps else {})\n\n return {\"claim\": new, \"overlaps\": overlaps}\n\ndef release(cfg: core.Config, *, agent: str, project: str = \"\") -> list[dict]:\n\n with core.locked(cfg):\n\n claims = load_claims(cfg)\n\n gone = [c for c in claims if c[\"agent\"] == agent and (not project or c[\"project\"] == project)]\n\n core.save_json(cfg.state / \"claims.json\", [c for c in claims if c not in gone])\n\n for c in gone:\n\n core.append_event(cfg, agent=agent, kind=\"release\", project=c[\"project\"], summary=f\"released: {c['summary']}\")\n\n return gone\n\n# ───────────────────────────── self-check report (read-only) ─────────────────────────────\n\ndef report(cfg: core.Config, days: int = 7) -> dict:\n\n \"\"\"Counts + plain hints. Never acts on them: too little data makes auto-tuning learn noise.\"\"\"\n\n since = _now() - timedelta(days=days)\n\n events = []\n\n for e in core.read_events(cfg):\n\n try:\n\n if datetime.fromisoformat(e[\"ts\"]) >= since:\n\n events.append(e)\n\n except (KeyError, ValueError):\n\n continue\n\n per_agent, unattributed, no_project, commits_by = {}, {}, 0, {}\n\n for e in events:\n\n a = e.get(\"agent\", \"?\")\n\n per_agent.setdefault(a, {}).setdefault(e.get(\"kind\", \"?\"), 0)\n\n per_agent[a][e.get(\"kind\", \"?\")] += 1\n\n if e.get(\"kind\") == \"commit\":\n\n commits_by[a] = commits_by.get(a, 0) + 1\n\n if a.startswith(\"git:\"):\n\n unattributed[a[4:]] = unattributed.get(a[4:], 0) + 1\n\n elif not e.get(\"project\") and not a.startswith(\"repo:\"):\n\n no_project += 1\n\n runs = [r for r in core.read_runs(cfg, 500) if \"steps\" in r]\n\n failed = sum(1 for r in runs if not r.get(\"ok\"))\n\n cur = core.build_current(core.read_events(cfg))\n\n stale = [b for b in cur[\"open_blocked\"] if b.get(\"ts\") and datetime.fromisoformat(b[\"ts\"]) < _now() - timedelta(hours=24)]\n\n hints = []\n\n for author, n in sorted(unattributed.items(), key=lambda kv: -kv[1]):\n\n hints.append(f\"{n} commit(s) by git author '{author}' name no tool: add an `Agent: <tool>` trailer to commits\")\n\n for tool in cfg.roster:\n\n if tool not in per_agent:\n\n hints.append(f\"{tool} logged nothing in {days} days: it may not be reading {_tilde(cfg.root / 'agents.md')}\")\n\n elif per_agent[tool].get(\"commit\") and not any(k for k in per_agent[tool] if k not in (\"commit\", \"claim\", \"release\")):\n\n hints.append(f\"{tool} committed but never ran `aisync log`\")\n\n if no_project:\n\n hints.append(f\"{no_project} event(s) without --project\")\n\n if stale:\n\n hints.append(f\"{len(stale)} blocker(s) open for more than 24 h\")\n\n if failed:\n\n hints.append(f\"{failed} of {len(runs)} sync cycles failed: see `aisync status`\")\n\n conf = core.load_json(cfg.state / \"conflicts.json\", {})\n\n if conf:\n\n hints.append(f\"rule conflict open in {', '.join(conf)}: `aisync resolve`\")\n\n return {\"days\": days, \"events\": len(events), \"per_agent\": per_agent, \"unattributed_commits\": unattributed,\n\n \"events_without_project\": no_project, \"stale_blockers\": len(stale), \"cycles\": len(runs),\n\n \"failed_cycles\": failed, \"open_conflicts\": sorted(conf), \"rules\": len(rule_list(cfg)), \"hints\": hints}\n\n# ───────────────────────────── progress fan-out to files tools auto-load ─────────────────────────────\n\ndef progress_text(cfg: core.Config, cur: dict, claims: list[dict], hints: list[str]) -> str:\n\n lines = [core.PROG_START,\n\n f\"## Live progress of all AI tools (aisync, auto-generated {core.now_iso()}, do not edit)\",\n\n \"\",\n\n f\"Shared rules: `{_tilde(cfg.root / 'preferences.md')}` (every agent follows them; change one with \"\n\n f\"`aisync rule add --agent <tool> \\\"...\\\"`). Full hub page: `{_tilde(cfg.root / 'agents.md')}`.\", \"\"]\n\n if claims:\n\n lines.append(\"**Working right now** (do not edit these files without coordinating)\")\n\n lines += [f\"- {c['agent']} @ {c['project']}: {core._short(c['summary'], 90)}\"\n\n f\"{' [' + ', '.join(c['files'][:6]) + ']' if c['files'] else ' [whole repo]'} until {c['expires'][11:16]}\"\n\n for c in claims]\n\n lines.append(\"\")\n\n lines.append(\"**Last seen per agent**\")\n\n for a, e in sorted(cur[\"agents\"].items(), key=lambda kv: kv[1][\"ts\"] or \"\", reverse=True)[:8]:\n\n lines.append(f\"- {a} {e['ts']} · {e['project'] or '-'}: {core._short(e['summary'], 100)}\")\n\n lines += [\"\", \"**Latest**\"] + [f\"- {e['ts']} · {e['agent']} · {e['kind']} · {e['project'] or '-'}: {core._short(e['summary'], 100)}\"\n\n for e in reversed(cur[\"tail\"][-6:])]\n\n if hints:\n\n lines += [\"\", \"**Hints from the self-check** (`aisync report`)\"] + [f\"- {h}\" for h in hints[:4]]\n\n lines.append(core.PROG_END)\n\n return \"\\n\".join(lines)\n\ndef _body(section: str) -> list[str]:\n\n \"\"\"Section minus its timestamped header line, for change detection.\"\"\"\n\n return [l for l in section.splitlines() if \"auto-generated\" not in l]\n\ndef render_progress(cfg: core.Config) -> dict:\n\n cur = core.build_current(core.read_events(cfg))\n\n section = progress_text(cfg, cur, load_claims(cfg), report(cfg)[\"hints\"])\n\n rep = {\"updated\": [], \"errors\": []}\n\n for t in cfg.progress_targets:\n\n try:\n\n if t.mode == \"file\":\n\n old = t.path.read_text() if t.path.exists() else \"\"\n\n if _body(old) != _body(section + \"\\n\"):\n\n core.atomic_write(t.path, section + \"\\n\")\n\n rep[\"updated\"].append(t.label)\n\n continue\n\n text = t.path.read_text()\n\n s, e = text.find(core.PROG_START), text.find(core.PROG_END)\n\n if s < 0 or e < 0:\n\n new = text.rstrip(\"\\n\") + \"\\n\\n\" + section + \"\\n\"\n\n else:\n\n if _body(text[s:e + len(core.PROG_END)]) == _body(section):\n\n continue\n\n new = text[:s] + section + text[e + len(core.PROG_END):]\n\n core.atomic_write(t.path, new)\n\n rep[\"updated\"].append(t.label)\n\n except OSError as ex:\n\n rep[\"errors\"].append(f\"{t.label}: {type(ex).__name__}\")   # one bad target never blocks the rest\n\n return rep", "url": "https://wpnews.pro/news/show-hn-aisync-exits-3-when-two-ai-agents-claim-the-same-file", "canonical_source": "https://github.com/MaxonT/aisync/blob/main/ai_sync/collab.py", "published_at": "2026-11-13 14:00:05+00:00", "updated_at": "2026-11-13 14:22:47.248303+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Aisync", "Claude Code", "Codex", "Cursor"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/show-hn-aisync-exits-3-when-two-ai-agents-claim-the-same-file", "markdown": "https://wpnews.pro/news/show-hn-aisync-exits-3-when-two-ai-agents-claim-the-same-file.md", "text": "https://wpnews.pro/news/show-hn-aisync-exits-3-when-two-ai-agents-claim-the-same-file.txt", "jsonld": "https://wpnews.pro/news/show-hn-aisync-exits-3-when-two-ai-agents-claim-the-same-file.jsonld"}}