iit-mcp-autotask 0.7.2 → 0.8.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/README.md CHANGED
@@ -9,9 +9,9 @@ packaged playbooks, strong security boundaries, and predictable per-tenant in-pr
9
9
  client + SDK-owned TTL caches).
10
10
 
11
11
  > **Package status (2026-10-19):** the runtime dependency `node-autotask` is published to public npm
12
- > at `0.18.0`; the committed `pnpm-lock.yaml` pins that
12
+ > at `0.19.0`; the committed `pnpm-lock.yaml` pins that
13
13
  > release with its integrity hash, so installs resolve deterministically. The dependency is always npm
14
- > (node-autotask pinned exactly at `0.18.0`) — never a `file:`, `git:`, `link:` or workspace reference
14
+ > (node-autotask pinned exactly at `0.19.0`) — never a `file:`, `git:`, `link:` or workspace reference
15
15
  > to a local checkout.
16
16
 
17
17
  > **0.6.0 is a breaking release** (unreleased): tool names, result shapes and configuration all
@@ -120,7 +120,7 @@ An operator deployment needs:
120
120
  |----------|---------|
121
121
  | `AUTOTASK_PROFILE=operator` (or an operator token) | the profile |
122
122
  | `AUTOTASK_POLICY_FILE` | the write policy: a closed JSON schema, default deny ([example](examples/policy.time-entries.json)) |
123
- | `AUTOTASK_STATE_DIR` | mode 0700; holds encrypted plans, the idempotency ledger, the hash-chained audit log and a lock (one process per state dir) |
123
+ | `AUTOTASK_STATE_DIR` | mode 0700; holds encrypted plans, the idempotency ledger, the hash-chained audit log, the primary lock and the holder registry (many processes may share one dir — see below) |
124
124
  | `AUTOTASK_RUN_SCOPE` | stdio: `{"companyIds":[...]}` or `{"all":true}` (the policy's own scope); it can only narrow the policy. On HTTP each operator token carries `runScope` instead |
125
125
  | `AUTOTASK_PRINCIPAL` | stdio: the name plans and the audit log are bound to (default `stdio`) |
126
126
  | `AUTOTASK_KILL_FILE` | optional: while this file exists every preview and apply is denied (default `<state dir>/KILL`) |
@@ -159,19 +159,25 @@ the same key only replays `unknown`. Re-read the target; if the write is absent,
159
159
  a new `idempotency_key` (a new attempt) after the 10-minute cooldown; its duplicate query refuses
160
160
  it if the first write did land. Payload field names are matched case-insensitively, and keys that
161
161
  differ only by case, or are not plain ASCII field names, are refused. `STATE_UNAVAILABLE` means the
162
- state dir could not be checked (`retry`) or the server lost its state lock (`stop`; see below). Use a separate Autotask API user for operator deployments, and run
162
+ state dir could not be checked (`retry`) or the server's own registration in the state dir was removed (`stop`; see below). Use a separate Autotask API user for operator deployments, and run
163
163
  the live write leg on throwaway data before relying on it (B7 in the 0.6.0 plan).
164
164
 
165
165
  #### The state lock: recovery
166
166
 
167
- One process owns a state dir through `<state dir>/lock` (its host, pid and a per-boot id). Run
168
- exactly one container per state dir: never share it between replicas or a rolling deploy's old and
169
- new containers.
167
+ Many processes may share a state dir (issue #68): every process registers its boot in a holder
168
+ registry (`<state dir>/holders/<boot>` plus a 5 s lease) before any other state work, and a
169
+ per-operation lock (`<state dir>/op.lock`) serialises the short shared-state critical sections —
170
+ the boot ledger rewrite, an apply's claim (the fresh ledger scan + the write-ahead `sending`
171
+ line), the apply's result append and every audit append. The dir still names ONE primary in
172
+ `<state dir>/lock` (host, pid and a per-boot id): a boot that finds a LIVE primary (same host,
173
+ a fresh lease) no longer refuses — it boots as a SECONDARY and shares the dir under the op lock.
170
174
 
171
- The holder refreshes a lease heartbeat (`<state dir>/lock.lease`) every 5 s, and a lease older
172
- than 15 s (3 refresh cycles) is expired. The takeover rules:
175
+ The primary keeps the #55 recovery machinery unchanged. The primary refreshes
176
+ `<state dir>/lock.lease` every 5 s, and a lease older than 15 s (3 refresh cycles) is expired.
177
+ The succession rules:
173
178
 
174
- - A live holder is never taken: a FRESH lease vetoes any takeover, whatever the pid probe says.
179
+ - A live primary is never taken: a FRESH lease vetoes any takeover, whatever the pid probe says.
180
+ A secondary boot under a live primary simply registers and shares; it never touches the lock.
175
181
  - After a crash, a SIGKILL or an OOM kill the next boot takes the lock over by itself when the
176
182
  old holder is gone, including a container where node is PID 1 again (the boot id tells the runs
177
183
  apart).
@@ -182,16 +188,16 @@ than 15 s (3 refresh cycles) is expired. The takeover rules:
182
188
  its own. With a new hostname (the docker default: the container id) the R6 host refusal applies
183
189
  and the operator deletes `<state dir>/lock` (the case below). Every takeover is appended to the
184
190
  `audit.jsonl` hash chain as a `lock_takeover` line, and `/ready` reports the lock's age, holder
185
- id and lease freshness, so an orchestrator can tell a stale lock from a live one.
191
+ id, lease freshness and the holder count, so an orchestrator can tell a stale lock from a live
192
+ one.
193
+
194
+ Boots sweep the dead registrations, and a `sending` ledger entry becomes `unknown` only when its
195
+ origin is not a live holder: a live peer's in-flight write stays `sending` (an apply of the same
196
+ key replays it, it is never re-sent), while a dead peer's — or a pre-0.8.0, unstamped — entry
197
+ becomes `unknown` exactly as in the single-process case.
186
198
 
187
199
  Cases that need an operator:
188
200
 
189
- - **`locked by a running process (pid N)`** (with or without `; its lease is X ms old`): another
190
- live server holds this state dir (the lease-less form is a pre-0.6.0 server, which never wrote
191
- one). Confirm with `ps -p N` that it is not a server you forgot, stop it, and start again. If it
192
- is really gone and the lock's pid is dead, no unlock is needed: the next boot takes the lock
193
- over immediately (a lease-less pre-0.6.0 lock has no lease to wait out). A live pid with no
194
- lease is never taken over - stop that server, or delete `<state dir>/lock`.
195
201
  - **`locked by host H (pid N); one container per state dir`**: the lock belongs to another host or
196
202
  container, whose pids mean nothing here, so it is never taken over automatically. Make sure the
197
203
  server on H is stopped (or the container is gone), then delete `<state dir>/lock` and start
@@ -201,12 +207,25 @@ Cases that need an operator:
201
207
  atomic renames, so this is not a crash remnant of our own). Make sure no server is running on
202
208
  this state dir, delete `<state dir>/lock.lease`, and start again.
203
209
  - **`another process is taking over the lock (<state dir>/lock.takeover)`**: a boot crashed in the
204
- middle of a takeover, a disk error hit mid-create, or two boots raced. Make sure no other server is starting on this state
205
- dir, delete `<state dir>/lock.takeover`, and start again.
206
-
207
- Never delete `lock` while a server is running: writes stop (`STATE_UNAVAILABLE`) the moment the
208
- running server notices it no longer holds the lock, and it stops refreshing the lease. Keep the
209
- rest of the state dir; `audit.jsonl` is the write history.
210
+ middle of a takeover, a disk error hit mid-create, or two boots raced and the wait ran out.
211
+ Make sure no other server is starting on this state dir, delete `<state dir>/lock.takeover`,
212
+ and start again.
213
+ - **`<state dir>/lock disappeared or is unreadable` / `names no pid`**: the lock file is gone or
214
+ corrupt (our writes are atomic, so this is not a crash remnant of our own). Make sure no server
215
+ is running on this state dir, delete `<state dir>/lock` (and `lock.lease`), and start again.
216
+ - **`the op lock could not be taken at boot: …`**: the per-operation lock was already stuck when
217
+ this boot started (the `op.lock` case below, seen at boot). Make sure no server is running on
218
+ this state dir, delete `<state dir>/op.lock` (and `op.lock.lease`), and start again.
219
+ - **`op.lock names no pid` / `a peer process holds <state dir>/op.lock; the wait ran out`**: the
220
+ per-operation lock is stuck — a boot crashed mid-operation and the 10 s wait ran out, or the
221
+ file is corrupt. Make sure no server is running on this state dir, delete `<state dir>/op.lock`
222
+ (and `op.lock.lease`), and start again.
223
+
224
+ Never delete a running server's registration (`<state dir>/holders/<boot>`) while it is running:
225
+ writes stop (`STATE_UNAVAILABLE`) the moment the server notices the file is gone, and it stops
226
+ refreshing the registration's lease. Deleting the primary `lock` out from under a running primary
227
+ is not a stop signal (the next boot simply takes the lock over) — the registration is the stop
228
+ signal. Keep the rest of the state dir; `audit.jsonl` is the write history.
210
229
 
211
230
  ### Query results: TSV, `output_path` and the saved-file shape
212
231
 
@@ -410,7 +429,7 @@ preview/apply path; a plan previewed under 0.6.0 cannot be applied after a rollb
410
429
  |----------|---------|------|
411
430
  | `POST /mcp` | the canonical MCP endpoint (Streamable HTTP transport); Host/Origin validated, 1 MiB body cap | a bearer token in `tokens` mode; per-request `X-Autotask-*` headers for tool calls |
412
431
  | `GET /health` | process alive — pre-auth, **no vendor call** (container healthcheck) | none |
413
- | `GET /ready` | config valid, SDK plan-hash pin checked, server initialised, client factory ready — **no live vendor call** (credentials are request-scoped). In an operator deployment the payload also carries `lock` (the state lock's age, holder id and lease freshness — see "The state lock: recovery") | none |
432
+ | `GET /ready` | config valid, SDK plan-hash pin checked, server initialised, client factory ready — **no live vendor call** (credentials are request-scoped). In an operator deployment the payload also carries `lock` (the state lock's age, holder id, lease freshness and the holder count — see "The state lock: recovery") | none |
414
433
  | `GET /doctor` | zero-wire credential/config preflight: per credential field `present`/`len`/`prefix2`/`shapeOk` (`AUTOTASK_SECRET`'s prefix is masked to `[REDACTED]` — no character of a secret is ever emitted; identifiers keep their format prefix), the base-url + allow-list resolution (`default_only` / `allow_list` / `default_and_list` / `any`), the SDK plan-hash gate status and the host-policy mode — **no vendor call, by construction** | Host/Origin validated; a bearer token in `tokens` mode |
415
434
  | `GET /metadata` | server identity + the MCP protocol **era pair** (baseline `2026-07-28` + legacy fallback `2025-11-25`) + capability-manifest pointer + the `deployment` block (profile, toolsets, approval mode, caller auth, rate limiting) — the served payload is the repo's `server.json` + build stamp | Host/Origin validated; a bearer token in `tokens` mode |
416
435
 
@@ -655,7 +674,7 @@ independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/
655
674
 
656
675
  ## SDK dependency
657
676
 
658
- `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.18.0`** (an exact pin —
677
+ `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.19.0`** (an exact pin —
659
678
  no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
660
679
  `/pool`) — the only **vendor** dependency; the other two runtime dependencies are
661
680
  `@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
@@ -665,7 +684,7 @@ user — the published tarball ships no lockfile) must resolve to the release th
665
684
  reviewed against, or the plan-hash boot check refuses to start.
666
685
 
667
686
  **Publishing `node-autotask` is the maintainer's action, done manually.** It is published on the
668
- public registry at `0.18.0` (published 2026-10-19); the
687
+ public registry at `0.19.0` (published 2026-10-19); the
669
688
  exact pin and the committed `pnpm-lock.yaml` (with `pnpm-workspace.yaml`) pin the real published
670
689
  tarball with its integrity hash, so installs resolve to exactly that release — the lockfile
671
690
  freezes the pnpm/git/Docker paths, the pin freezes the npm/npx paths. The docs are written against this
package/dist/main.js CHANGED
@@ -189,6 +189,19 @@ function main() {
189
189
  log.error('uncaught exception', { err: err instanceof Error ? (err.stack ?? err.message) : String(err) });
190
190
  void handle.close().finally(() => process.exit(1));
191
191
  });
192
+ // SIGTERM/SIGINT: close the transport and exit through process.exit, whose 'exit' hook
193
+ // releases the state dir (issue #68: a terminated process must not leave its holder
194
+ // registration behind for the next boot to sweep).
195
+ let stopping = false;
196
+ const stop = (signal) => {
197
+ if (stopping)
198
+ return; // a second signal mid-close: the first one is finishing it
199
+ stopping = true;
200
+ log.info('shutting down', { signal });
201
+ void handle.close().finally(() => process.exit(0));
202
+ };
203
+ process.on('SIGTERM', () => stop('SIGTERM'));
204
+ process.on('SIGINT', () => stop('SIGINT'));
192
205
  return;
193
206
  }
194
207
  // tokens mode: the tokens file is read and validated once, before the listener binds.
@@ -96,7 +96,7 @@ export declare function cacheOptionsForPreset(preset: 'on' | 'off', fieldCacheTt
96
96
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
97
97
  * describes the SDK behind it.
98
98
  */
99
- export declare const BUILT_AGAINST_PLAN_HASH = "5176d37fc9bae34139c151ae0ea8336dab63a30382f04acbcaed0e24cdd71d5f";
99
+ export declare const BUILT_AGAINST_PLAN_HASH = "078f288c280029abcbc632ac9a842ba952296ef28872c28da67a08af6ed76475";
100
100
  /**
101
101
  * Fail fast when the installed SDK's plan has moved out from under this surface.
102
102
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -159,7 +159,7 @@ export function cacheOptionsForPreset(preset, fieldCacheTtlMs) {
159
159
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
160
160
  * describes the SDK behind it.
161
161
  */
162
- export const BUILT_AGAINST_PLAN_HASH = '5176d37fc9bae34139c151ae0ea8336dab63a30382f04acbcaed0e24cdd71d5f';
162
+ export const BUILT_AGAINST_PLAN_HASH = '078f288c280029abcbc632ac9a842ba952296ef28872c28da67a08af6ed76475';
163
163
  /**
164
164
  * Fail fast when the installed SDK's plan has moved out from under this surface.
165
165
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -28,7 +28,7 @@ import { type TokenStore } from '../http-guard.js';
28
28
  import type { WriteRuntime } from '../write/runtime.js';
29
29
  /** The wire identity of this server (the initialize answer + the `/metadata` payload). */
30
30
  export declare const SERVER_NAME = "iit-mcp-autotask";
31
- export declare const SERVER_VERSION = "0.7.2";
31
+ export declare const SERVER_VERSION = "0.8.0";
32
32
  export declare const SERVER_TITLE = "Autotask MCP Server";
33
33
  /**
34
34
  * The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
@@ -60,7 +60,7 @@ export declare function serverInstructions(config: Pick<Config, 'AUTOTASK_PROFIL
60
60
  /** The full identity stamp (the `/metadata` build block — the era pair, FR-2). */
61
61
  export declare const SERVER_IDENTITY: {
62
62
  readonly name: "iit-mcp-autotask";
63
- readonly version: "0.7.2";
63
+ readonly version: "0.8.0";
64
64
  readonly mcpProtocolBaseline: "2026-07-28";
65
65
  readonly legacyProtocolFallback: "2025-11-25";
66
66
  };
@@ -29,7 +29,7 @@ import { registerTools } from './register.js';
29
29
  import { configForToken } from '../http-guard.js';
30
30
  /** The wire identity of this server (the initialize answer + the `/metadata` payload). */
31
31
  export const SERVER_NAME = 'iit-mcp-autotask';
32
- export const SERVER_VERSION = '0.7.2';
32
+ export const SERVER_VERSION = '0.8.0';
33
33
  export const SERVER_TITLE = 'Autotask MCP Server';
34
34
  /**
35
35
  * The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
@@ -604,21 +604,37 @@ export function buildApply(ctx) {
604
604
  return refused('denied', 'stop', reason('PRINCIPAL_MISMATCH', 'this plan belongs to another principal or another Autotask tenant credential'), plan);
605
605
  }
606
606
  return store.withApplyLock(async () => {
607
- const hit = store.lookup(plan.idemKey);
608
- if (hit !== undefined) {
607
+ const base = { idemKey: plan.idemKey, planHash: plan.planHash, targetKey: plan.facts.targetKey, op: plan.op };
608
+ // The hit answer — shared by the FIRST lookup (its old position) and the claim's rescan:
609
+ // the same plan hash answers with the recorded outcome (`replayed: true`), except a
610
+ // retryable failure that wrote nothing (a 429) which may run again under the same key;
611
+ // another plan hash is `IDEMPOTENCY_CONFLICT`, `verify_then_repreview` (pre-release V4:
612
+ // not a dead end). `undefined` means the hit does not end the apply.
613
+ const hitAnswer = (hit) => {
609
614
  if (hit.planHash !== plan.planHash) {
610
- // Pre-release V4: not a dead end. The key already ran another plan (for example a retry
611
- // re-previewed after the state moved on): re-read, and run anything still needed as a new attempt.
612
615
  const was = hit.state === 'sending' ? 'unknown' : hit.state;
613
616
  return refused('denied', 'verify_then_repreview', reason('IDEMPOTENCY_CONFLICT', `this idempotency key already ran a different plan (outcome ${was}). Do not re-apply under this key: re-read the target to see whether that write landed; if a write is still needed, preview again under a new idempotency_key (a new run id or attempt)`), plan);
614
617
  }
615
- // F1: a retryable failure (a 429, or refused before it was sent) wrote nothing, so the same
616
- // plan may run again under the same key; the SDK's own cooldown still gates the send.
617
- // Everything else replays: succeeded, a deterministic failure, and above all `unknown` and a
618
- // lingering `sending`, which are never re-sent (a new attempt needs a new key and a fresh
619
- // preview, whose duplicate query finds a write that did land).
618
+ // F1: a retryable failure (a 429, or refused before it was sent) wrote nothing, so the
619
+ // same plan may run again under the same key; the SDK's own cooldown still gates the
620
+ // send. Everything else replays: succeeded, a deterministic failure, and above all
621
+ // `unknown` and a lingering `sending` (a peer's in-flight write, issue #68), which are
622
+ // never re-sent (a new attempt needs a new key and a fresh preview, whose duplicate
623
+ // query finds a write that did land).
620
624
  if (!(hit.state === 'failed' && hit.outcome?.retryable === true))
621
625
  return outcomeAnswer(plan, hit, { replayed: true });
626
+ return undefined;
627
+ };
628
+ // GATES in the old relative order (issue #68): the first lookup is a FRESH from-disk
629
+ // scan, no op lock — a same-key replay NEVER dials the vendor, so an apply after a
630
+ // landed write answers from the ledger; a snapshot re-read before this gate would read
631
+ // the CHANGED vendor state, drift, and refuse before reaching the replay path (the
632
+ // ordering flaw that broke acceptance criterion 3).
633
+ const hit = store.lookup(plan.idemKey);
634
+ if (hit !== undefined) {
635
+ const early = hitAnswer(hit);
636
+ if (early !== undefined)
637
+ return early;
622
638
  }
623
639
  if (now() > plan.expiresAt)
624
640
  return refused('error', 'repreview', reason('PLAN_EXPIRED', 'the plan is older than 15 minutes; preview again'), plan);
@@ -632,6 +648,9 @@ export function buildApply(ctx) {
632
648
  if (store.coolingDown(plan.facts.targetKey, now())) {
633
649
  return refused('denied', 'verify_then_repreview', reason('COOLDOWN_ACTIVE', 'an earlier write to this target has an unknown outcome; re-read it, then preview again after the 10-minute cooldown'), plan);
634
650
  }
651
+ // The snapshot re-read (the only vendor call before the send) stays OUTSIDE the op lock
652
+ // — the claim's critical section must never span a vendor round-trip (issue #68) — in
653
+ // its old position: after the gates above, before the claim.
635
654
  const record = ctx.record(plan.op);
636
655
  const shape = writeShape(record);
637
656
  const parsed = { args: plan.args, targets: plan.targets, ...(SHAPE_ARGS[shape].includes('data') ? { data: plan.args.at(-1) } : {}) };
@@ -649,14 +668,34 @@ export function buildApply(ctx) {
649
668
  if ('code' in snap || snap.hash !== plan.snapshotHash) {
650
669
  return refused('error', 'repreview', reason('STATE_DRIFT', 'the target changed since the preview; preview again'), plan);
651
670
  }
652
- // Last point a cancellation or the kill switch stops the write: nothing has been sent.
671
+ // Last point a cancellation stops the write before anything is claimed: nothing has been sent.
653
672
  if (signal?.aborted === true)
654
673
  return abortedResult();
655
- const late = store.blocked();
656
- if (late !== undefined)
657
- return refused(late.next === 'retry' ? 'error' : 'denied', late.next, reason(late.code, late.detail), plan, { retryable: late.next === 'retry' });
658
- const base = { idemKey: plan.idemKey, planHash: plan.planHash, targetKey: plan.facts.targetKey, op: plan.op };
659
- store.append({ ...base, state: 'sending', at: now() });
674
+ // The CLAIM (issue #68): one op-lock critical section — the blocked() re-check, a FRESH
675
+ // ledger re-scan and the `sending` append. The re-scan closes the cross-process race: a
676
+ // peer's claim landing between the first lookup and here is visible inside the lock,
677
+ // and the loser gets the honest hit answer (replay/unknown) without sending. The
678
+ // expiry/policy/counters/cooldown checks stay OUTSIDE it — they already ran above, and
679
+ // the file is the single source of truth, so a locked re-check would add nothing.
680
+ const opBlocked = store.acquireOpLock();
681
+ if (opBlocked !== undefined) {
682
+ return refused('error', 'retry', reason(opBlocked.code, opBlocked.detail), plan, { retryable: true });
683
+ }
684
+ try {
685
+ const late = store.blocked();
686
+ if (late !== undefined)
687
+ return refused(late.next === 'retry' ? 'error' : 'denied', late.next, reason(late.code, late.detail), plan, { retryable: late.next === 'retry' });
688
+ const rescan = store.lookup(plan.idemKey);
689
+ if (rescan !== undefined) {
690
+ const lateHit = hitAnswer(rescan);
691
+ if (lateHit !== undefined)
692
+ return lateHit;
693
+ }
694
+ store.append({ ...base, state: 'sending', at: now() });
695
+ }
696
+ finally {
697
+ store.releaseOpLock();
698
+ }
660
699
  store.audit({ event: 'apply_start', principal: w.principal, op: plan.op, targets: plan.targets, planHash: plan.planHash, decision: 'sending', reasons: [] }, now());
661
700
  // The SDK write: no MCP signal (M7, never aborted once sending), capped at the apply wait.
662
701
  // Retries are off via the PER-CALL maxRetries: 0 on the tenant client (node-autotask#105:
@@ -700,23 +739,38 @@ export function buildApply(ctx) {
700
739
  finally {
701
740
  clearTimeout(timer);
702
741
  }
703
- store.append(entry);
704
- // The last blocked() gate was before the vendor send: the lock may have been taken over
705
- // while the write was in flight. A GENUINE loss (STATE_UNAVAILABLE with next === 'stop':
706
- // the lock was deleted or replaced) throws - the completed write's audit line can no
707
- // longer be appended by us, and answering success for an unaudited write is worse than a
708
- // loud failure (S2-M2). A kill switch that comes up during the send does NOT throw: the
709
- // lock is still held, the audit line appends, and the kill switch stops the NEXT write
710
- // (the main-branch behaviour, kept deliberately). A transient unreadable lock
711
- // (STATE_UNAVAILABLE with next === 'retry', V7) proceeds to the audit as normal: the
712
- // write completed, and the audit's skip-and-log guard keeps the chain honest if the lock
713
- // truly was lost in the meantime.
742
+ // The result append takes the op lock (issue #68 review): one O_APPEND line is not
743
+ // enough — a peer that boots between our open('a') and writeSync renames the ledger out
744
+ // from under the write, and the `succeeded` line (with its `created` block) is orphaned
745
+ // on the old inode. Under the op lock the append serialises with the boot rewrite and
746
+ // with the peer's claim, and the apply_result audit rides the SAME acquisition (audit is
747
+ // re-entrant-aware: it appends directly while the lock is ours).
748
+ const blockedAfter = store.acquireOpLock();
749
+ if (blockedAfter !== undefined) {
750
+ throw new ConfigError(`AUTOTASK_STATE_DIR: the write may have landed on the vendor side, but its result could not be recorded (${blockedAfter.detail}); re-read the target before any retry`);
751
+ }
752
+ try {
753
+ store.append(entry);
754
+ const head = store.audit({ event: 'apply_result', principal: w.principal, op: plan.op, targets: plan.targets, planHash: plan.planHash, decision: entry.state, reasons: entry.outcome?.code === undefined ? [] : [entry.outcome.code] }, now());
755
+ ctx.log.info('write applied', { op: plan.op, outcome: entry.state, auditHead: head });
756
+ }
757
+ finally {
758
+ store.releaseOpLock();
759
+ }
760
+ // The last blocked() gate was before the vendor send: this process's holder registration
761
+ // may have been removed while the write was in flight. A GENUINE stop (STATE_UNAVAILABLE
762
+ // with next === 'stop': the registration was deleted) throws — answering success for a
763
+ // write that an external actor has already stopped is worse than a loud failure (the
764
+ // ledger and audit lines WERE written; the failure is about this process's standing).
765
+ // A kill switch that comes up during the send does NOT throw: the registration is still
766
+ // present, the audit line appends, and the kill switch stops the NEXT write (the
767
+ // main-branch behaviour, kept deliberately). A transient unreadable registration
768
+ // (STATE_UNAVAILABLE with next === 'retry') proceeds to the audit as normal: the write
769
+ // completed.
714
770
  const after = store.blocked();
715
771
  if (after !== undefined && after.code === 'STATE_UNAVAILABLE' && after.next === 'stop') {
716
- throw new ConfigError(`AUTOTASK_STATE_DIR: the state lock was lost while the write was in flight; the ledger entry was written to this process's ledger descriptor (the successor's boot may have rewritten the ledger since, so it may not be visible in the file), and the audit line was not appended (${after.detail})`);
772
+ throw new ConfigError(`AUTOTASK_STATE_DIR: this process's holder registration was removed while the write was in flight; the write completed on the vendor side and its ledger and audit lines were recorded, but this process is stopped (${after.detail})`);
717
773
  }
718
- const head = store.audit({ event: 'apply_result', principal: w.principal, op: plan.op, targets: plan.targets, planHash: plan.planHash, decision: entry.state, reasons: entry.outcome?.code === undefined ? [] : [entry.outcome.code] }, now());
719
- ctx.log.info('write applied', { op: plan.op, outcome: entry.state, auditHead: head });
720
774
  if (entry.state !== 'succeeded') {
721
775
  const data = error === undefined ? {} : { error: honestError(typedErrorPayload(error, ctx.correlationId, requestSecrets(ctx)), error, entry) };
722
776
  return outcomeAnswer(plan, entry, { replayed: false, data });
@@ -4,8 +4,9 @@ export declare const PLAN_TTL_MS: number;
4
4
  export declare const LEDGER_RETENTION_MS: number;
5
5
  export declare const COOLDOWN_MS: number;
6
6
  /**
7
- * The lease heartbeat: while it holds the lock, the holder rewrites `<state>/lock.lease`
8
- * atomically at this interval (issue #55).
7
+ * The lease heartbeat: while it is primary, the holder rewrites `<state>/lock.lease` atomically
8
+ * at this interval (issue #55); EVERY process also refreshes its own registration's lease here
9
+ * (issue #68) — one timer, both heartbeats.
9
10
  *
10
11
  * 5 s: one small 0600 file rewrite + fsync every 5 s is negligible against the apply path, and it
11
12
  * keeps the takeover delay after a container replacement short (the lease is dead within one
@@ -16,14 +17,16 @@ export declare const LEASE_REFRESH_MS = 5000;
16
17
  * A lease older than this is expired: the holder has been silent for three full refresh cycles,
17
18
  * so its lock may be taken over. A healthy event loop does not stall for 15 s (GC, fsync and OS
18
19
  * scheduling are far shorter), so a live holder is never taken in practice; a pathological stall
19
- * longer than this CAN be taken over, and the stalled process detects the lost lock and stops
20
- * new writes (`STATE_UNAVAILABLE`) the moment it resumes; an apply already in flight at the
21
- * takeover writes its ledger entry to its own descriptor (the honest outcome - the successor's
22
- * boot rewrites the ledger, so it may not be visible in the file) and skips its audit append
23
- * (a forked chain is worse than a missing line), while the explicit re-check in the tool fails
24
- * a genuinely lost lock loudly so the chain is never forked.
20
+ * longer than this CAN be taken over, and the stalled process detects the loss and stops new
21
+ * writes (`STATE_UNAVAILABLE`) the moment it resumes. The same expiry window is the op lock's
22
+ * dead-man's switch: holds are milliseconds, so a holder whose op lease went stale is a crashed
23
+ * mid-op process, and the next acquire takes the op lock over.
25
24
  */
26
25
  export declare const LEASE_EXPIRY_MS: number;
26
+ /** The op lock's poll interval while a peer holds it (holds are milliseconds; 10 ms is plenty). */
27
+ export declare const OP_LOCK_POLL_MS = 10;
28
+ /** The op lock's wait cap: a live holder almost always releases long before this. */
29
+ export declare const OP_LOCK_WAIT_MS = 10000;
27
30
  /** What a plan names as its target: the resource and the ids a snapshot read. */
28
31
  export interface PlanTargets {
29
32
  resource: string;
@@ -72,6 +75,10 @@ export interface LedgerEntry {
72
75
  principal: string;
73
76
  tenantId: string;
74
77
  };
78
+ /** The per-boot identity of the process that appended this line (issue #68); absent on legacy entries. */
79
+ boot?: string;
80
+ /** The pid of the process that appended this line (issue #68); absent on legacy entries. */
81
+ pid?: number;
75
82
  }
76
83
  /** The identity fields of a credential (never its secret): what the tenant id is an HMAC of. */
77
84
  export interface TenantIdentity {
@@ -96,7 +103,7 @@ export interface AuditEvent {
96
103
  }
97
104
  /** The facts /ready reports on the state lock (issue #55): age, holder id, lease freshness. */
98
105
  export interface LockInfo {
99
- /** This process holds the lock. */
106
+ /** This process is the primary (holds the lock). */
100
107
  held: boolean;
101
108
  /** The holder the lock currently names (absent when the lock is gone or unreadable). */
102
109
  holder?: {
@@ -110,7 +117,15 @@ export interface LockInfo {
110
117
  leaseAgeMs?: number;
111
118
  /** True when a readable lease is older than {@link LEASE_EXPIRY_MS}. */
112
119
  leaseExpired?: boolean;
120
+ /** How many processes have registered in `<state>/holders/` (including this one, issue #68). No liveness filter: dead registrations are swept at the next boot. */
121
+ holders: number;
113
122
  }
123
+ /** Why an op-lock acquisition gave up: the bounded wait ran out, or the op lock needs an operator. */
124
+ export type OpLockBlocked = {
125
+ code: 'STATE_UNAVAILABLE';
126
+ next: 'retry';
127
+ detail: string;
128
+ };
114
129
  export interface StateStore {
115
130
  readonly dir: string;
116
131
  tenantId(identity: TenantIdentity): string;
@@ -118,9 +133,9 @@ export interface StateStore {
118
133
  savePlan(plan: PlanRecord): void;
119
134
  /** The decrypted plan, or undefined when absent or not authentic (forged, edited, swapped). */
120
135
  loadPlan(handle: string): PlanRecord | undefined;
121
- /** The latest ledger entry for an idempotency key. */
136
+ /** The latest ledger entry for an idempotency key (a fresh scan of the file). */
122
137
  lookup(idemKey: string): LedgerEntry | undefined;
123
- /** Append + fsync. */
138
+ /** Append + fsync (one O_APPEND line; the line is stamped with this boot's identity). */
124
139
  append(entry: LedgerEntry): void;
125
140
  counters(op: string, now: number): Counters;
126
141
  /** True while an unknown outcome on this target is younger than the cooldown. */
@@ -132,31 +147,45 @@ export interface StateStore {
132
147
  createdHere(resource: string, id: number, now: number, days: number, principal: string, tenantId: string): {
133
148
  companyIds: number[];
134
149
  } | undefined;
135
- /** Append one chained audit line; returns the new head hash. When the lock is no longer held, the line is skipped (logged) and the current head is returned (a forked chain is worse than a missing line). */
150
+ /**
151
+ * Append one chained audit line; returns the new head hash. The append runs under the op lock,
152
+ * which adopts the file's tail when the chain advanced under a peer; the line is skipped (logged)
153
+ * and the current head returned only when the store is closed or the op lock cannot be taken.
154
+ */
136
155
  audit(event: AuditEvent, now: number): string;
137
156
  /** The facts /ready reports on the state lock: who holds it, how long, and how fresh its lease is. */
138
157
  lockInfo(): LockInfo;
139
158
  killed(): boolean;
140
159
  /**
141
- * Why writes may not run right now: the kill switch (stop), the state lock lost (stop), or the
142
- * lock unreadable for the moment (retry); undefined when writes may run.
160
+ * Why writes may not run right now: the kill switch (stop), this process's holder registration
161
+ * gone (stop) or unreadable for the moment (retry); undefined when writes may run.
143
162
  */
144
163
  blocked(): {
145
164
  code: 'KILL_SWITCH' | 'STATE_UNAVAILABLE';
146
165
  next: 'stop' | 'retry';
147
166
  detail: string;
148
167
  } | undefined;
149
- /** One apply at a time per process (and, through the lock file, per state dir). */
168
+ /**
169
+ * Take the per-operation lock (issue #68). Returns undefined when acquired — the caller must
170
+ * then call {@link releaseOpLock} — or the reason the bounded wait gave up.
171
+ */
172
+ acquireOpLock(): OpLockBlocked | undefined;
173
+ /** Release the per-operation lock if it is still this process's. */
174
+ releaseOpLock(): void;
175
+ /** One apply at a time per process. */
150
176
  withApplyLock<T>(fn: () => Promise<T>): Promise<T>;
151
177
  close(): void;
152
178
  }
153
179
  /**
154
- * Take the state lock: O_EXCL, or a takeover of a holder that is no longer active.
180
+ * Take the state lock: O_EXCL, or a takeover of a holder that is no longer active, or — when the
181
+ * current holder is a LIVE primary — a plain secondary boot (issue #68): the lock stays with the
182
+ * primary and this process shares the dir under the op lock.
155
183
  *
156
184
  * The takeover is serialised by a second O_EXCL file, `lock.takeover` (review F7): only its holder
157
185
  * may replace the stale lock, and it re-reads the lock first, so two processes booting after a crash
158
- * cannot both take over (the loser sees either the takeover file or the winner's live pid + fresh
159
- * lease). A takeover file left by a crash mid-takeover refuses boot until an operator removes it.
186
+ * cannot both take over (the loser sees either the takeover file — and now WAITS for the taker
187
+ * instead of refusing — or the winner's live pid + fresh lease). A takeover file that survives the
188
+ * wait cap (a crash mid-takeover left it behind) refuses boot until an operator removes it.
160
189
  *
161
190
  * The verdict has three parts (issue #55): another host's lock is never taken (R6); a FRESH lease
162
191
  * (the holder rewrote `lock.lease` within {@link LEASE_EXPIRY_MS}) vetoes the takeover outright —
@@ -182,6 +211,8 @@ export interface OpenStoreOptions {
182
211
  /** `AUTOTASK_KILL_FILE`; default `<state>/KILL`. */
183
212
  killFile?: string;
184
213
  now?: () => number;
214
+ /** The op lock's wait cap (and the lock-takeover contention wait). Default {@link OP_LOCK_WAIT_MS}. */
215
+ opLockWaitMs?: number;
185
216
  }
186
217
  /** Open (or create) the state directory; throws ConfigError on every boot refusal. */
187
218
  export declare function openStateStore(dir: string, opts: OpenStoreOptions): StateStore;