wicked-crew 0.15.2 → 0.15.4

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.
Files changed (36) hide show
  1. package/dist/api/gate-cache.d.ts +98 -17
  2. package/dist/api/gate-cache.d.ts.map +1 -1
  3. package/dist/api/gate-cache.js +136 -25
  4. package/dist/api/gate-cache.js.map +1 -1
  5. package/dist/api/recording.d.ts +3 -2
  6. package/dist/api/recording.d.ts.map +1 -1
  7. package/dist/api/recording.js +13 -1
  8. package/dist/api/recording.js.map +1 -1
  9. package/dist/api/routes.d.ts.map +1 -1
  10. package/dist/api/routes.js +537 -344
  11. package/dist/api/routes.js.map +1 -1
  12. package/dist/api/seat-work-default.d.ts +22 -0
  13. package/dist/api/seat-work-default.d.ts.map +1 -0
  14. package/dist/api/seat-work-default.js +91 -0
  15. package/dist/api/seat-work-default.js.map +1 -0
  16. package/dist/api/server.js +1 -1
  17. package/dist/api/server.js.map +1 -1
  18. package/dist/studio/assets/{index-BhpoNOI0.js → index-CK-ppFAd.js} +218 -217
  19. package/dist/studio/assets/index-Chzb3FOa.css +32 -0
  20. package/dist/studio/editors/wicked-deck/editor.json +1 -1
  21. package/dist/studio/editors/wicked-deck/index.html +1 -1
  22. package/dist/studio/editors/wicked-doc/editor.json +1 -1
  23. package/dist/studio/editors/wicked-doc/index.html +1 -1
  24. package/dist/studio/editors/wicked-page/editor.json +1 -1
  25. package/dist/studio/editors/wicked-page/index.html +1 -1
  26. package/dist/studio/editors/wicked-video/editor.json +1 -1
  27. package/dist/studio/editors/wicked-video/index.html +1 -1
  28. package/dist/studio/index.html +2 -2
  29. package/dist/studio/testid-inventory.json +87 -27
  30. package/dist/team/routes.d.ts +11 -1
  31. package/dist/team/routes.d.ts.map +1 -1
  32. package/dist/team/routes.js +58 -48
  33. package/dist/team/routes.js.map +1 -1
  34. package/endpoint-manifest.json +1 -1
  35. package/package.json +4 -3
  36. package/dist/studio/assets/index-BEFOUR6c.css +0 -32
@@ -13,7 +13,7 @@ import { videoPalette } from '../appearance/video-palette.js';
13
13
  import { CampaignsUnsupportedError, ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, GovernanceReplayUnsupportedError, PlanLaunchUnsupportedError, SteeringUnsupportedError, addonSupportsQeOverride, addonSupportsReducedAssurance, humanGatePhaseIds, settingsFilePath } from '../core/adapter.js';
14
14
  import { codeGraphDb, codeGraphErrorStatus, requirementsGraph } from '../core/repoPaths.js';
15
15
  import { resolveCursorUnit } from '../core/cursor.js';
16
- import { detectRefusal } from './gate-cache.js';
16
+ import { detectRefusal, isOpenGate, offeredChoices, replayOpenGate } from './gate-cache.js';
17
17
  import { acceptanceRequirementOf, buildAcceptanceView } from '../qe/acceptance.js';
18
18
  import { runIdentityOf, wireIdentity } from '../core/run-identity.js';
19
19
  import { buildEvidenceBundle, coreUnitId, evidenceFilename } from './evidence.js';
@@ -26,6 +26,7 @@ import { applyCouncilSettings, isCouncilSize, isCouncilStaggerMs } from '../core
26
26
  import { defaultLaunchRoster, markOptIn } from '../core/opt-in-seats.js';
27
27
  import { chatSeatAdmission } from './seat-standing.js';
28
28
  import { seatGovernanceDiagnostics, seatGovernanceWarnings, } from './seat-governance.js';
29
+ import { seatWorkDefaultNow } from './seat-work-default.js';
29
30
  import { ROSTER_REFRESH_WAIT_MS, rosterWithStandingFactory } from './roster-standing.js';
30
31
  import { SeatContainerStatus, isContainerLoginCli, isContainerSeatFloor, loginDetail, runSeatLogin, seatLoginDepsFor, } from './seat-container-login.js';
31
32
  import { ChatTurnIndex } from './chat-turns.js';
@@ -1223,6 +1224,9 @@ runtime = {}) {
1223
1224
  // crew#742: a seat that runs ungoverned, or floor seats on a host that cannot arm their
1224
1225
  // boundary, are named before any run starts.
1225
1226
  ...seatGovernanceWarnings(seatGovernanceNow(), hostSandboxLauncher()),
1227
+ // crew 0.15.2 (#916): a custom seat written council-off with no work flag is now work-eligible
1228
+ // by the engine default; each is named once, so the operator can keep it off work.
1229
+ ...seatWorkDefaultNow(),
1226
1230
  // crew#1068: the engine has no wicked-vault CLI to record held boundary findings with.
1227
1231
  ...[adapter.vaultBin != null ? vaultBinWarning(adapter.vaultBin) : null].filter((w) => w !== null),
1228
1232
  ];
@@ -1297,6 +1301,8 @@ runtime = {}) {
1297
1301
  rosterFile: seatRosterFile(),
1298
1302
  hostBoundary: hostSandboxLauncher(),
1299
1303
  seats: seatGovernanceNow(),
1304
+ // The same `seat.work_default` findings `/health.warnings` carries (api-types 0.133.0).
1305
+ roleFindings: seatWorkDefaultNow(),
1300
1306
  },
1301
1307
  // crew#740: which memory store this daemon reads and writes, and the isolation notice.
1302
1308
  memoryStore: memoryStoreInfo(),
@@ -3342,8 +3348,33 @@ runtime = {}) {
3342
3348
  // single-shot carrier (codex on the wrapped carrier) remembers nothing of the first turn, so a
3343
3349
  // follow-up told only the question answered "the supplied repository contains only tmp".
3344
3350
  const instructions = scope !== undefined ? `${chatScopeStatement(id, scope, rules)}\n\n${withPreface(pending.preface)}` : withPreface(pending.preface);
3345
- await adapter.proposePlan(path.runId, { steps: [answerStep(stepId, instructions)] }, turnId);
3346
- await adapter.confirmGate(path.runId, true);
3351
+ // The continuation answers the path run's gate: under the per-run decision lock it re-reads the
3352
+ // run and is judged by the record before it proposes or confirms anything (crew#1083 round 7).
3353
+ // The status read above is a pre-check that changes nothing; this read is the one that counts.
3354
+ const pathRunId = path.runId;
3355
+ const refused = await decideExclusively(pathRunId, async () => {
3356
+ const view = (await adapter.sessionsDetail()).find((v) => v.session.id === pathRunId);
3357
+ if (view === undefined || view.session.status !== 'awaiting_human') {
3358
+ return { code: 409, body: {
3359
+ code: 'turn_in_flight',
3360
+ error: `chat '${id}': its path ${pathRunId} is ${view?.session.status ?? 'gone'}, not waiting for your turn — nothing was sent`,
3361
+ chatId: id,
3362
+ runId: pathRunId,
3363
+ } };
3364
+ }
3365
+ const decided = await gateForDecision(view, undefined);
3366
+ if ('refusal' in decided)
3367
+ return decided.refusal;
3368
+ await adapter.proposePlan(pathRunId, { steps: [answerStep(stepId, instructions)] }, turnId);
3369
+ await adapter.confirmGate(pathRunId, true);
3370
+ return null;
3371
+ });
3372
+ if (refused !== null) {
3373
+ askPaths.release(id, lease);
3374
+ if (turn !== null)
3375
+ chatTurns.abort(id, turn.turnId);
3376
+ return reply.code(refused.code).send(refused.body);
3377
+ }
3347
3378
  askPaths.release(id, lease);
3348
3379
  if (!askPaths.continued(id, lease, stepId)) {
3349
3380
  // Closed between the proposal and here: the DELETE cancelled the run this was accepted on.
@@ -3809,54 +3840,124 @@ runtime = {}) {
3809
3840
  }
3810
3841
  }
3811
3842
  /**
3812
- * The run's OPEN gate, resolved the way `GET /runs/:id/gate` serves it: the cache, then the
3813
- * engine's durable `interaction_requests` row (adopted into the cache), then the event-log replay.
3814
- * The caller has already checked `awaiting_human`. `null` = the log records no open gate;
3815
- * `'no-log'` = this build cannot read the log, so nothing can be said.
3843
+ * The run's OPEN gate, resolved the way `GET /runs/:id/gate` serves it: the cache, else a read of
3844
+ * the record (`readOpenGate`) adopted into the cache only once it completes with no live gate
3845
+ * event of the run's having landed meanwhile — `GateCache.resolve` re-runs a read one did
3846
+ * overlap, because neither side of such an overlap is known to be the newer (crew#1083 review).
3847
+ * The caller has already checked `awaiting_human`. `null` = the record holds no open gate;
3848
+ * `'no-log'` = this build cannot read the log, so nothing can be said; `'unstable'` = live gate
3849
+ * events kept landing while the record was read, so nothing was verified (retry).
3816
3850
  */
3817
3851
  async function resolveOpenGate(id) {
3818
- const cached = gateCache.get(id);
3819
- if (cached)
3820
- return cached;
3821
- const durableRows = typeof adapter.interactionRequests === 'function'
3822
- ? await adapter.interactionRequests(id, 'open')
3823
- : null;
3824
- const durableGate = durableRows?.find((r) => r.kind === 'gate');
3825
- if (durableGate !== undefined) {
3852
+ return gateCache.resolve(id, () => readOpenGate(id));
3853
+ }
3854
+ /**
3855
+ * One read of the run's open gate from the record: the engine's durable `interaction_requests`
3856
+ * row (DES-PROJECT-001 §5.3), enriched from the event log, else the event-log replay for engines
3857
+ * predating the table. Never consults the cache — `GateCache.resolve` owns that, and runs this
3858
+ * again when a live gate event overlaps it.
3859
+ */
3860
+ async function readOpenGate(id) {
3861
+ const recorded = await recordedOpenGate(id);
3862
+ if (recorded?.state === 'ambiguous')
3863
+ return 'ambiguous';
3864
+ if (recorded?.state === 'open') {
3865
+ const durableGate = recorded.row;
3826
3866
  // This path builds the entry inline (not through `fold`), so it must run the SAME refusal
3827
3867
  // detection (issue #419) — otherwise a gate served from the durable row after a restart would
3828
3868
  // silently drop the warning that the live/replay paths carry.
3829
3869
  const refusal = detectRefusal(durableGate.prompt);
3830
3870
  const gateKind = typeof durableGate['gate_kind'] === 'string' && durableGate['gate_kind'] !== '' ? durableGate['gate_kind'] : undefined;
3831
- const entry = {
3832
- ord: durableGate.ord ?? 0,
3871
+ const ord = recorded.ord;
3872
+ // Issue #969: the row has no `choices`/`recommended`, so they come from the newest
3873
+ // `awaitingHuman` for this ord in the event log. A log this build cannot read (null, or a read
3874
+ // that throws) leaves both out — the gate is still served, just without the offered answers.
3875
+ const events = await adapter.runEvents(id).catch(() => null);
3876
+ const frame = events?.filter((e) => e.type === 'awaitingHuman' && e.session === id && e.ord === ord).at(-1);
3877
+ return {
3878
+ ord,
3833
3879
  prompt: durableGate.prompt,
3834
3880
  lifecycle: 'open',
3835
3881
  receivedAt: new Date(durableGate.created_at).toISOString(),
3836
3882
  ...(refusal !== undefined ? { refusal } : {}),
3837
3883
  ...(gateKind !== undefined ? { gateKind } : {}),
3884
+ ...(frame !== undefined ? offeredChoices(frame) : {}),
3838
3885
  };
3839
- gateCache.adopt(id, entry);
3840
- return entry;
3841
3886
  }
3842
3887
  const events = await adapter.runEvents(id);
3843
3888
  if (events === null)
3844
3889
  return 'no-log';
3845
- return gateCache.rebuild(id, events) ?? null;
3890
+ return replayOpenGate(id, events) ?? null;
3846
3891
  }
3847
- /** The unit an open gate holds: the named `ord`, else the open gate's, else the cursor's. */
3848
- async function gatedUnitOf(run, ord) {
3849
- let gateOrd = ord;
3850
- if (gateOrd === undefined) {
3851
- // A route set whose adapter cannot read the gate (a partial stub without `runEvents`) falls
3852
- // back to the cursor unit, exactly as a log that records no open gate does.
3853
- const open = await resolveOpenGate(run.session.id).catch(() => null);
3854
- gateOrd = open !== null && open !== 'no-log' ? open.ord : undefined;
3855
- }
3856
- const units = run.units ?? [];
3857
- return gateOrd !== undefined
3858
- ? units.find((u) => u.ord === gateOrd)
3859
- : [...units].sort((a, b) => a.ord - b.ord)[run.session.unit_ix];
3892
+ /**
3893
+ * The engine's durable record of the run's open gate: ONE reading of its open `interaction_requests`
3894
+ * gate rows, which `GET /runs/:id/gate` serves and every decision path is judged by (crew#1083
3895
+ * round 5 — GET once took the first row while POST took the newest). `null` = this engine keeps
3896
+ * no rows. Rows that disagree on the ord are `ambiguous`: the record cannot say which gate is
3897
+ * open, and says so. Agreeing rows — one, or several: the engine can hold a keyed plan_approval
3898
+ * gate whose publisher was lost plus the unkeyed team_transport row boot recovery writes for the
3899
+ * same ord (wicked-core team_gate.rs) — are `open` at that ord, and the row read is the newest
3900
+ * by `created_at`.
3901
+ */
3902
+ async function recordedOpenGate(id) {
3903
+ if (typeof adapter.interactionRequests !== 'function')
3904
+ return null;
3905
+ const rows = await adapter.interactionRequests(id, 'open');
3906
+ if (rows === null)
3907
+ return null;
3908
+ const gates = rows.filter((r) => r.kind === 'gate');
3909
+ if (gates.length === 0)
3910
+ return { state: 'none' };
3911
+ const ords = [...new Set(gates.map((r) => r.ord ?? 0))].sort((a, b) => a - b);
3912
+ if (ords.length > 1)
3913
+ return { state: 'ambiguous', ords };
3914
+ const at = (r) => new Date(r.created_at).getTime() || 0;
3915
+ const row = gates.reduce((a, b) => (at(b) >= at(a) ? b : a));
3916
+ return { state: 'open', ord: ords[0], row };
3917
+ }
3918
+ /**
3919
+ * The gate a decision answers, judged by the record (crew#1083 rounds 3–5) — the one check every
3920
+ * gate-confirming path makes, inside {@link decideExclusively}, before its delivery checks and its
3921
+ * confirm: `POST /runs/:id/gate` and standing orders (through `decideGate`), a gated
3922
+ * `POST /runs/:id/resume`, a gated `POST /runs/:id/reassign`. The record's open gate, never the
3923
+ * cache's: after a confirm the cache can still hold the answered gate until its `resumed` frame is
3924
+ * pumped, and a decision judged by it would answer the next gate with the last one's checks.
3925
+ *
3926
+ * - The record is ambiguous (open rows disagree on the ord) → 409 `gate_unknown`.
3927
+ * - It shows a gate open: a decision naming another ord → 409 `gate_changed` with `openOrd`;
3928
+ * otherwise the gated unit is that gate's, and the prompt and kind are its row's.
3929
+ * - It shows none (or the engine keeps no rows): a decision naming its ord → 409 `gate_unknown`.
3930
+ * A decision without one keeps the pre-table resolution (the cache, then the log, then the
3931
+ * cursor unit) — an engine, or a run, older than the table.
3932
+ */
3933
+ async function gateForDecision(run, namedOrd) {
3934
+ const id = run.session.id;
3935
+ const unitAt = (ord) => (run.units ?? []).find((u) => u.ord === ord);
3936
+ const unknown = (why) => ({ refusal: { code: 409, body: { error: `Gate unknown: ${why} — refresh and decide again.`, code: 'gate_unknown' } } });
3937
+ const recorded = await recordedOpenGate(id);
3938
+ if (recorded?.state === 'ambiguous') {
3939
+ return unknown(`the engine's record shows gates open before units ${recorded.ords.join(' and ')} on this run, so the daemon cannot tell which one a decision answers`);
3940
+ }
3941
+ if (recorded?.state === 'open') {
3942
+ if (namedOrd !== undefined && namedOrd !== recorded.ord) {
3943
+ return { refusal: { code: 409, body: {
3944
+ error: `Gate changed: this decision was made on the gate before unit ${namedOrd}, but the open gate `
3945
+ + `is before unit ${recorded.ord} — it was answered or replaced. Read the open gate before deciding.`,
3946
+ code: 'gate_changed',
3947
+ openOrd: recorded.ord,
3948
+ } } };
3949
+ }
3950
+ const kind = recorded.row['gate_kind'];
3951
+ return { gated: unitAt(recorded.ord), prompt: recorded.row.prompt, gateKind: typeof kind === 'string' && kind !== '' ? kind : undefined };
3952
+ }
3953
+ if (namedOrd !== undefined) {
3954
+ return unknown(`this decision names the gate before unit ${namedOrd}, but the engine's record `
3955
+ + `${recorded === null ? 'keeps no durable gate row' : 'shows no open gate'} for this run, so the daemon cannot confirm the named one`);
3956
+ }
3957
+ const open = await resolveOpenGate(id).catch(() => null);
3958
+ if (isOpenGate(open))
3959
+ return { gated: unitAt(open.ord), prompt: open.prompt, gateKind: open.gateKind };
3960
+ return { gated: [...(run.units ?? [])].sort((a, b) => a.ord - b.ord)[run.session.unit_ix], prompt: undefined, gateKind: undefined };
3860
3961
  }
3861
3962
  /**
3862
3963
  * The checks an APPROVE that runs the deliver unit must pass — the ONE place for both gate paths
@@ -3873,130 +3974,149 @@ runtime = {}) {
3873
3974
  }
3874
3975
  return { qeAcceptance };
3875
3976
  }
3977
+ /** Per run: the tail of the decisions queued by {@link decideExclusively}. Never rejects. */
3978
+ const gateDecisionTails = new Map();
3979
+ /**
3980
+ * Run `fn` alone for the run (crew#1083 round 4; the `McpPolicies.exclusive` pattern of #1082):
3981
+ * after every earlier decision for the same run has settled, and before any later one starts.
3982
+ * The row read and the confirm are separate engine commands and the confirm carries no ord, so
3983
+ * two concurrent decisions could both read the sole open ord-3 row, the first confirm advance
3984
+ * the run to its ord-4 (deliver) gate, and the second confirm answer THAT gate with the delivery
3985
+ * checks evaluated for ord 3 — a studio double-click on Approve is enough. Serialized, the second
3986
+ * re-reads inside the lock and meets ord 4: 409 `gate_changed`. Other runs are not blocked.
3987
+ *
3988
+ * Every crew API path that confirms a gate takes it and is judged by the record
3989
+ * ({@link gateForDecision}) under it: `decideGate` (`POST /runs/:id/gate`, standing orders),
3990
+ * `POST /runs/:id/resume`, `POST /runs/:id/reassign` (the re-seat included), `POST /runs/:id/plan`
3991
+ * (team routes) and the chat ask path's continuation (which re-reads its run inside the lock).
3992
+ * It serializes these paths; it does not bind a confirm to the gate the operator saw. A
3993
+ * decision without an `ord` (`/resume`, `/reassign`, `/plan`) queued behind one that advanced the
3994
+ * run answers the gate the record shows then, and the stall watchdog's `reassignUnit` runs
3995
+ * outside this lock. Both need every confirm bound to the gate it answers: an ord on the engine's
3996
+ * confirm (wicked-core#1038) and the skin sending it.
3997
+ */
3998
+ function decideExclusively(runId, fn) {
3999
+ const result = (gateDecisionTails.get(runId) ?? Promise.resolve()).then(fn);
4000
+ const tail = result.catch(() => undefined);
4001
+ gateDecisionTails.set(runId, tail);
4002
+ void tail.then(() => {
4003
+ if (gateDecisionTails.get(runId) === tail)
4004
+ gateDecisionTails.delete(runId);
4005
+ });
4006
+ return result;
4007
+ }
3876
4008
  /**
3877
4009
  * THE gate decision path. `POST /runs/:id/gate` answers with it, and so does a standing order
3878
4010
  * (behaviour 10) — the same awaiting check, the same ord check and 409 `gate_changed` /
3879
4011
  * `gate_unknown`, the same `confirmGate`, the same `gate.decided` audit. There is no side path:
3880
- * an order is just another actor, named on the audit line (`extraDetail.standingOrder`).
4012
+ * an order is just another actor, named on the audit line (`extraDetail.standingOrder`). One
4013
+ * decision at a time per run ({@link decideExclusively}).
3881
4014
  */
3882
4015
  async function decideGate(id, decision, actor, extraDetail = {}) {
3883
4016
  const parsed = GateSchema.safeParse(decision);
3884
4017
  if (!parsed.success) {
3885
4018
  return { code: 400, body: invalidBody(parsed.error, 'Invalid request body') };
3886
4019
  }
3887
- const views = await adapter.sessionsDetail();
3888
- const run = views.find((v) => v.session.id === id);
3889
- if (!run)
3890
- return { code: 404, body: { error: 'Run not found' } };
3891
- if (run.session.status !== 'awaiting_human') {
3892
- return { code: 409, body: { error: `Run is not awaiting a human gate (status: ${run.session.status})` } };
3893
- }
3894
- // api-types 0.44.0: a decision that names its gate answers THAT gate or nothing. When the
3895
- // daemon cannot tell which gate is open (no cache, no durable row, no log binding, or a log
3896
- // recording no open gate) it cannot confirm the named one either, so it refuses — the skin
3897
- // re-reads the gate and the person decides again. A request without `ord` is unchanged.
3898
- if (parsed.data.ord !== undefined) {
3899
- const open = await resolveOpenGate(id);
3900
- if (open === null || open === 'no-log') {
3901
- return { code: 409, body: {
3902
- error: `Gate unknown: this decision names the gate before unit ${parsed.data.ord}, but the daemon cannot `
3903
- + 'tell which gate is open on this run — refresh and decide again.',
3904
- code: 'gate_unknown',
3905
- } };
3906
- }
3907
- if (open.ord !== parsed.data.ord) {
3908
- return { code: 409, body: {
3909
- error: `Gate changed: this decision was made on the gate before unit ${parsed.data.ord}, but the open gate `
3910
- + `is before unit ${open.ord} — it was answered or replaced. Read the open gate before deciding.`,
3911
- code: 'gate_changed',
3912
- openOrd: open.ord,
3913
- } };
3914
- }
3915
- }
3916
- // The gated unit — the named gate, else the open one, else the unit the run's cursor points at —
3917
- // and its receipt as it stands when the person decides (wicked-core#850: every gate answer
3918
- // carries it). An APPROVE that would run the deliver unit passes the delivery checks first.
3919
- const gated = await gatedUnitOf(run, parsed.data.ord);
3920
- const assurance = receiptOf(gated);
3921
- let deliveryCheck;
3922
- if (parsed.data.approve && gated !== undefined && isDeliverUnit(gated)) {
3923
- const refused = await deliverApprovalRefusal(run, gated);
3924
- if ('refusal' in refused)
3925
- return refused.refusal;
3926
- deliveryCheck = { qeAcceptance: refused.qeAcceptance };
3927
- }
3928
- // The steering-author landing (crew#388): decided — and the gate prompt captured — BEFORE
3929
- // the confirm, because a terminal-phase approve prunes the gate cache and moves the run out
3930
- // of `awaiting_human`. The landing itself runs AFTER a successful approve only. The run is
3931
- // classified by its recorded name (seam X2), so a partial-stub adapter with no registry cannot
3932
- // 500 the legacy gate path here.
3933
- const steeringPropose = parsed.data.approve && isSteeringAuthorRun(run);
3934
- const gatePrompt = steeringPropose ? gateCache.get(id)?.prompt : undefined;
3935
- try {
3936
- // All five positionals, explicit `undefined` for the absent ones (DES-L1 PR-2; the
3937
- // gate-arms tripwire pins the arity). An engine refusal (no creator to send back to,
3938
- // an engine older than the arm) surfaces as the existing 409 below.
3939
- // (operator ruling 2026-10-11) WHO answered: the adapter hands it to the engine with this
3940
- // confirm, which records it as the judge when this approve answers a `judge_unavailable`
3941
- // gate ("judged by operator").
3942
- adapter.recordGateActor?.(id, `${actor.id} (${actor.kind})`);
3943
- const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend, parsed.data.action, parsed.data.amendScope, parsed.data.plan !== undefined ? toLaunchPlan(parsed.data.plan) : undefined);
3944
- // WHO approved/rejected — the gate-decision audit (task #88). The engine
3945
- // records THAT the gate resolved (interaction_requests / gateDecided);
3946
- // only this HTTP layer knows the authenticated principal behind it.
3947
- audit.record('gate.decided', actor, {
3948
- runId: id,
3949
- detail: {
3950
- approve: parsed.data.approve,
3951
- ...(parsed.data.amend !== undefined ? { amend: parsed.data.amend } : {}),
3952
- ...(parsed.data.action !== undefined ? { action: parsed.data.action } : {}),
3953
- ...(parsed.data.amendScope !== undefined ? { amendScope: parsed.data.amendScope } : {}),
3954
- ...(parsed.data.plan !== undefined ? { planSteps: parsed.data.plan.steps.length } : {}),
3955
- ...(parsed.data.ord !== undefined ? { ord: parsed.data.ord } : {}),
3956
- ...extraDetail,
3957
- status,
3958
- },
3959
- });
3960
- // DC-S4a host: the gate note (amend / request_changes text) — a bare approve or reject is a
3961
- // `choice`. Post-commit, off the request path; a non-human actor (a standing order) records nothing.
3962
- if (decisions !== null) {
3963
- decisions.track(ingestDecision(decisions, {
3964
- host: 'gate',
3965
- actor,
3966
- words: parsed.data.amend ?? '',
3967
- choice: parsed.data.action ?? (parsed.data.approve ? 'approve' : 'reject'),
3968
- projectId: projects.index.projectOf(id) ?? null,
4020
+ return decideExclusively(id, async () => {
4021
+ const views = await adapter.sessionsDetail();
4022
+ const run = views.find((v) => v.session.id === id);
4023
+ if (!run)
4024
+ return { code: 404, body: { error: 'Run not found' } };
4025
+ if (run.session.status !== 'awaiting_human') {
4026
+ return { code: 409, body: { error: `Run is not awaiting a human gate (status: ${run.session.status})` } };
4027
+ }
4028
+ // Decision integrity (api-types 0.44.0; crew#1083 rounds 3–5): the gate this decision answers
4029
+ // is the RECORD's — read first thing inside the lock, so the gated unit and its delivery
4030
+ // checks below are the ones the record confirms ({@link gateForDecision}).
4031
+ const decided = await gateForDecision(run, parsed.data.ord);
4032
+ if ('refusal' in decided)
4033
+ return decided.refusal;
4034
+ // The gated unit and its receipt as it stands when the person decides (wicked-core#850: every
4035
+ // gate answer carries it). An APPROVE that would run the deliver unit passes the delivery
4036
+ // checks first.
4037
+ const gated = decided.gated;
4038
+ const assurance = receiptOf(gated);
4039
+ let deliveryCheck;
4040
+ if (parsed.data.approve && gated !== undefined && isDeliverUnit(gated)) {
4041
+ const refused = await deliverApprovalRefusal(run, gated);
4042
+ if ('refusal' in refused)
4043
+ return refused.refusal;
4044
+ deliveryCheck = { qeAcceptance: refused.qeAcceptance };
4045
+ }
4046
+ // The steering-author landing (crew#388): decided — and the gate prompt captured — BEFORE
4047
+ // the confirm, because a terminal-phase approve prunes the gate cache and moves the run out
4048
+ // of `awaiting_human`. The landing itself runs AFTER a successful approve only. The run is
4049
+ // classified by its recorded name (seam X2), so a partial-stub adapter with no registry cannot
4050
+ // 500 the legacy gate path here. The prompt is the record's row (agy, round 4): the cache
4051
+ // holds nothing after a restart and another gate's words after a late frame.
4052
+ const steeringPropose = parsed.data.approve && isSteeringAuthorRun(run);
4053
+ const gatePrompt = steeringPropose ? decided.prompt : undefined;
4054
+ try {
4055
+ // All five positionals, explicit `undefined` for the absent ones (DES-L1 PR-2; the
4056
+ // gate-arms tripwire pins the arity). An engine refusal (no creator to send back to,
4057
+ // an engine older than the arm) surfaces as the existing 409 below.
4058
+ // (operator ruling 2026-10-11) WHO answered: the adapter hands it to the engine with this
4059
+ // confirm, which records it as the judge when this approve answers a `judge_unavailable`
4060
+ // gate ("judged by operator").
4061
+ adapter.recordGateActor?.(id, `${actor.id} (${actor.kind})`);
4062
+ const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend, parsed.data.action, parsed.data.amendScope, parsed.data.plan !== undefined ? toLaunchPlan(parsed.data.plan) : undefined);
4063
+ // WHO approved/rejected — the gate-decision audit (task #88). The engine
4064
+ // records THAT the gate resolved (interaction_requests / gateDecided);
4065
+ // only this HTTP layer knows the authenticated principal behind it.
4066
+ audit.record('gate.decided', actor, {
3969
4067
  runId: id,
3970
- ...(parsed.data.ord !== undefined ? { ord: parsed.data.ord } : {}),
3971
- }));
4068
+ detail: {
4069
+ approve: parsed.data.approve,
4070
+ ...(parsed.data.amend !== undefined ? { amend: parsed.data.amend } : {}),
4071
+ ...(parsed.data.action !== undefined ? { action: parsed.data.action } : {}),
4072
+ ...(parsed.data.amendScope !== undefined ? { amendScope: parsed.data.amendScope } : {}),
4073
+ ...(parsed.data.plan !== undefined ? { planSteps: parsed.data.plan.steps.length } : {}),
4074
+ ...(parsed.data.ord !== undefined ? { ord: parsed.data.ord } : {}),
4075
+ ...extraDetail,
4076
+ status,
4077
+ },
4078
+ });
4079
+ // DC-S4a host: the gate note (amend / request_changes text) — a bare approve or reject is a
4080
+ // `choice`. Post-commit, off the request path; a non-human actor (a standing order) records nothing.
4081
+ if (decisions !== null) {
4082
+ decisions.track(ingestDecision(decisions, {
4083
+ host: 'gate',
4084
+ actor,
4085
+ words: parsed.data.amend ?? '',
4086
+ choice: parsed.data.action ?? (parsed.data.approve ? 'approve' : 'reject'),
4087
+ projectId: projects.index.projectOf(id) ?? null,
4088
+ runId: id,
4089
+ ...(parsed.data.ord !== undefined ? { ord: parsed.data.ord } : {}),
4090
+ }));
4091
+ }
4092
+ // APPROVE of the steering-author propose gate = the doctrine's landing moment: the
4093
+ // approved proposal is written to the governance store with `provenance.source: "chat"`,
4094
+ // audited per rule, idempotent on replay, and LOUD on failure — the response carries the
4095
+ // outcome either way, so the studio can show "landed PAT-101" or the explicit error
4096
+ // instead of the silent no-op crew#388 recorded. An approve WITH an amend note still
4097
+ // lands the proposal UNCHANGED: the amend steers the RUN's continuation, not the rule
4098
+ // text (an operator who wants different rules rejects and re-authors). A reject lands
4099
+ // nothing — the run cancels and the proposal stays an artifact.
4100
+ const landing = steeringPropose
4101
+ ? await landSteeringProposal({ adapter, audit, actor }, run, gatePrompt)
4102
+ : undefined;
4103
+ return {
4104
+ code: 200,
4105
+ body: {
4106
+ status,
4107
+ ...(landing !== undefined ? { landing } : {}),
4108
+ assurance,
4109
+ ...(deliveryCheck !== undefined ? { delivery: deliveryCheck } : {}),
4110
+ },
4111
+ };
3972
4112
  }
3973
- // APPROVE of the steering-author propose gate = the doctrine's landing moment: the
3974
- // approved proposal is written to the governance store with `provenance.source: "chat"`,
3975
- // audited per rule, idempotent on replay, and LOUD on failure — the response carries the
3976
- // outcome either way, so the studio can show "landed PAT-101" or the explicit error
3977
- // instead of the silent no-op crew#388 recorded. An approve WITH an amend note still
3978
- // lands the proposal UNCHANGED: the amend steers the RUN's continuation, not the rule
3979
- // text (an operator who wants different rules rejects and re-authors). A reject lands
3980
- // nothing — the run cancels and the proposal stays an artifact.
3981
- const landing = steeringPropose
3982
- ? await landSteeringProposal({ adapter, audit, actor }, run, gatePrompt)
3983
- : undefined;
3984
- return {
3985
- code: 200,
3986
- body: {
3987
- status,
3988
- ...(landing !== undefined ? { landing } : {}),
3989
- assurance,
3990
- ...(deliveryCheck !== undefined ? { delivery: deliveryCheck } : {}),
3991
- },
3992
- };
3993
- }
3994
- catch (err) {
3995
- if (err instanceof PlanLaunchUnsupportedError) {
3996
- return { code: 501, body: { error: message(err) } };
4113
+ catch (err) {
4114
+ if (err instanceof PlanLaunchUnsupportedError) {
4115
+ return { code: 501, body: { error: message(err) } };
4116
+ }
4117
+ return { code: 409, body: { error: message(err) } };
3997
4118
  }
3998
- return { code: 409, body: { error: message(err) } };
3999
- }
4119
+ });
4000
4120
  }
4001
4121
  // The steering gate (§11.1). approve+amend = approve-with-steer; approve:false = reject (cancels).
4002
4122
  app.post(`${V}/runs/:id/gate`, {
@@ -4034,79 +4154,86 @@ runtime = {}) {
4034
4154
  // resumeRun re-enters the cursor. Never resumeRun a gated run (it would re-pause).
4035
4155
  app.post(`${V}/runs/:id/resume`, async (req, reply) => {
4036
4156
  const { id } = req.params;
4037
- const views = await adapter.sessionsDetail();
4038
- const run = views.find((v) => v.session.id === id);
4039
- if (!run)
4040
- return reply.code(404).send({ error: 'Run not found' });
4041
- // crew#418 A: a run stranded by a deliver-phase lift collision reads `failed` from the engine
4042
- // but its work is complete and liftable — normalize to `completed` so the terminal branch
4043
- // below refuses resume with the DELIVER recovery (POST /runs/:id/deliver), not a re-entry.
4044
- const conflictStrand = normalizeStranded(run);
4045
- // A TERMINAL run is refused LOUDLY, with the actual recovery named (crew#311 defect 2).
4046
- // The engine's `resume_run` no-ops on completed/cancelled and answers the status token, so
4047
- // this route used to reply 200 {"status":"cancelled"} — on the exact runs an operator was
4048
- // trying to rescue, the recovery affordance read as "resume destroyed my run". A cancelled
4049
- // run has NO in-run recovery (the path is a retry launch, `POST /runs {retryOf}`); a
4050
- // completed run has nothing to resume — but a stranded one's work IS liftable post-hoc.
4051
- // The 409 body carries the machine-readable pointer (`ResumeRefusal` in api-types).
4052
- const terminal = run.session.status;
4053
- if (terminal === 'completed' || terminal === 'cancelled') {
4054
- const state = resolveDelivery(run, conflictStrand);
4055
- const recovery = terminal === 'completed' && state.delivery === 'stranded'
4056
- ? 'deliver'
4057
- : 'retry';
4058
- const why = terminal === 'cancelled'
4059
- ? `run ${id} is cancelled — a terminal run cannot be resumed; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`
4060
- : state.delivery === 'stranded'
4061
- ? `run ${id} is already completed — nothing to resume; its unlifted work is on the wicked/${id} branch: deliver it with POST /runs/${id}/deliver`
4062
- : state.delivery === 'pushed'
4063
- ? `run ${id} is already completed and delivered — its branch ${state.deliverBranch ?? `wicked/${id}`} is on ${state.deliverRemote ?? 'origin'} (no pull request: that origin is not a GitHub host gh can resolve); nothing to resume`
4064
- : state.delivery === 'vacuous'
4065
- ? `run ${id} is already completed, but VACUOUSLY — its units produced no work to resume or deliver; relaunch with POST /runs {"retryOf":"${id}"}`
4066
- : `run ${id} is already completed — nothing to resume; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`;
4067
- return reply.code(409).send({ error: why, recovery });
4068
- }
4069
- try {
4070
- const gated = run.session.status === 'awaiting_human';
4071
- // A gated resume IS a gate approval, so it lands a steering-author proposal exactly like
4072
- // POST /runs/:id/gate would — the "no side door" rule (task #88) holds for the landing
4073
- // write too (crew#388). Prompt captured pre-confirm; see the gate route.
4074
- const steeringPropose = gated && isSteeringAuthorRun(run);
4075
- const gatePrompt = steeringPropose ? gateCache.get(id)?.prompt : undefined;
4076
- // A gated resume IS an approve, so it passes the same delivery checks (freeze, EX-03) — no side
4077
- // door around the deliver gate — and answers with the gated unit's receipt.
4078
- const gatedUnit = gated ? await gatedUnitOf(run, undefined) : undefined;
4079
- let deliveryCheck;
4080
- if (gatedUnit !== undefined && isDeliverUnit(gatedUnit)) {
4081
- const refused = await deliverApprovalRefusal(run, gatedUnit);
4082
- if ('refusal' in refused)
4083
- return reply.code(refused.refusal.code).send(refused.refusal.body);
4084
- deliveryCheck = { qeAcceptance: refused.qeAcceptance };
4157
+ // A gated resume confirms the gate: one decision at a time per run (crew#1083 round 5).
4158
+ return decideExclusively(id, async () => {
4159
+ const views = await adapter.sessionsDetail();
4160
+ const run = views.find((v) => v.session.id === id);
4161
+ if (!run)
4162
+ return reply.code(404).send({ error: 'Run not found' });
4163
+ // crew#418 A: a run stranded by a deliver-phase lift collision reads `failed` from the engine
4164
+ // but its work is complete and liftable — normalize to `completed` so the terminal branch
4165
+ // below refuses resume with the DELIVER recovery (POST /runs/:id/deliver), not a re-entry.
4166
+ const conflictStrand = normalizeStranded(run);
4167
+ // A TERMINAL run is refused LOUDLY, with the actual recovery named (crew#311 defect 2).
4168
+ // The engine's `resume_run` no-ops on completed/cancelled and answers the status token, so
4169
+ // this route used to reply 200 {"status":"cancelled"} — on the exact runs an operator was
4170
+ // trying to rescue, the recovery affordance read as "resume destroyed my run". A cancelled
4171
+ // run has NO in-run recovery (the path is a retry launch, `POST /runs {retryOf}`); a
4172
+ // completed run has nothing to resume — but a stranded one's work IS liftable post-hoc.
4173
+ // The 409 body carries the machine-readable pointer (`ResumeRefusal` in api-types).
4174
+ const terminal = run.session.status;
4175
+ if (terminal === 'completed' || terminal === 'cancelled') {
4176
+ const state = resolveDelivery(run, conflictStrand);
4177
+ const recovery = terminal === 'completed' && state.delivery === 'stranded'
4178
+ ? 'deliver'
4179
+ : 'retry';
4180
+ const why = terminal === 'cancelled'
4181
+ ? `run ${id} is cancelled — a terminal run cannot be resumed; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`
4182
+ : state.delivery === 'stranded'
4183
+ ? `run ${id} is already completed — nothing to resume; its unlifted work is on the wicked/${id} branch: deliver it with POST /runs/${id}/deliver`
4184
+ : state.delivery === 'pushed'
4185
+ ? `run ${id} is already completed and delivered — its branch ${state.deliverBranch ?? `wicked/${id}`} is on ${state.deliverRemote ?? 'origin'} (no pull request: that origin is not a GitHub host gh can resolve); nothing to resume`
4186
+ : state.delivery === 'vacuous'
4187
+ ? `run ${id} is already completed, but VACUOUSLY — its units produced no work to resume or deliver; relaunch with POST /runs {"retryOf":"${id}"}`
4188
+ : `run ${id} is already completed — nothing to resume; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`;
4189
+ return reply.code(409).send({ error: why, recovery });
4085
4190
  }
4086
- if (gated) {
4087
- const who = actorOf(req);
4088
- adapter.recordGateActor?.(id, `${who.id} (${who.kind})`);
4191
+ try {
4192
+ const gated = run.session.status === 'awaiting_human';
4193
+ // A gated resume IS a gate approval, so it lands a steering-author proposal exactly like
4194
+ // POST /runs/:id/gate would — the "no side door" rule (task #88) holds for the landing
4195
+ // write too (crew#388). Prompt captured pre-confirm; see the gate route.
4196
+ // A gated resume IS an approve, so it is judged by the record like every decision, passes the
4197
+ // same delivery checks (freeze, EX-03) — no side door around the deliver gate — and answers
4198
+ // with the gated unit's receipt.
4199
+ const decided = gated ? await gateForDecision(run, undefined) : undefined;
4200
+ if (decided !== undefined && 'refusal' in decided)
4201
+ return reply.code(decided.refusal.code).send(decided.refusal.body);
4202
+ const steeringPropose = gated && isSteeringAuthorRun(run);
4203
+ const gatePrompt = steeringPropose ? decided?.prompt : undefined;
4204
+ const gatedUnit = decided?.gated;
4205
+ let deliveryCheck;
4206
+ if (gatedUnit !== undefined && isDeliverUnit(gatedUnit)) {
4207
+ const refused = await deliverApprovalRefusal(run, gatedUnit);
4208
+ if ('refusal' in refused)
4209
+ return reply.code(refused.refusal.code).send(refused.refusal.body);
4210
+ deliveryCheck = { qeAcceptance: refused.qeAcceptance };
4211
+ }
4212
+ if (gated) {
4213
+ const who = actorOf(req);
4214
+ adapter.recordGateActor?.(id, `${who.id} (${who.kind})`);
4215
+ }
4216
+ const status = gated ? await adapter.confirmGate(id, true) : await adapter.resumeRun(id);
4217
+ // A resume of a gated run IS a gate approval — audit it as one, so the
4218
+ // "who approved" trail has no side door (task #88).
4219
+ audit.record(gated ? 'gate.decided' : 'run.resumed', actorOf(req), {
4220
+ runId: id,
4221
+ detail: gated ? { approve: true, via: 'resume', status } : { status },
4222
+ });
4223
+ const landing = steeringPropose
4224
+ ? await landSteeringProposal({ adapter, audit, actor: actorOf(req) }, run, gatePrompt)
4225
+ : undefined;
4226
+ return reply.send({
4227
+ status,
4228
+ ...(landing !== undefined ? { landing } : {}),
4229
+ ...(gated ? { assurance: receiptOf(gatedUnit) } : {}),
4230
+ ...(deliveryCheck !== undefined ? { delivery: deliveryCheck } : {}),
4231
+ });
4089
4232
  }
4090
- const status = gated ? await adapter.confirmGate(id, true) : await adapter.resumeRun(id);
4091
- // A resume of a gated run IS a gate approval — audit it as one, so the
4092
- // "who approved" trail has no side door (task #88).
4093
- audit.record(gated ? 'gate.decided' : 'run.resumed', actorOf(req), {
4094
- runId: id,
4095
- detail: gated ? { approve: true, via: 'resume', status } : { status },
4096
- });
4097
- const landing = steeringPropose
4098
- ? await landSteeringProposal({ adapter, audit, actor: actorOf(req) }, run, gatePrompt)
4099
- : undefined;
4100
- return reply.send({
4101
- status,
4102
- ...(landing !== undefined ? { landing } : {}),
4103
- ...(gated ? { assurance: receiptOf(gatedUnit) } : {}),
4104
- ...(deliveryCheck !== undefined ? { delivery: deliveryCheck } : {}),
4105
- });
4106
- }
4107
- catch (err) {
4108
- return reply.code(409).send({ error: message(err) });
4109
- }
4233
+ catch (err) {
4234
+ return reply.code(409).send({ error: message(err) });
4235
+ }
4236
+ });
4110
4237
  });
4111
4238
  // Inject an operator message into a run's active worker(s) (§11.7).
4112
4239
  // target="all" broadcasts; any other value targets a specific CLI key.
@@ -4139,22 +4266,25 @@ runtime = {}) {
4139
4266
  return reply.code(409).send({ error: message(err) });
4140
4267
  }
4141
4268
  });
4142
- /** crew#1058: the runs with a gated reassign (re-seat, then approve) in flight — one at a time. */
4143
- const gatedReassigns = new Set();
4269
+ /**
4270
+ * crew#1058 / #1083 round 7: the runs with a reassign in flight. A second reassign of the same run
4271
+ * is refused before it queues on the decision lock — queued, it would act on the state the first
4272
+ * left (re-seat a worker the first just started, or approve twice).
4273
+ */
4274
+ const reassignsInFlight = new Set();
4144
4275
  /**
4145
4276
  * codex r1 on #1060: whether approving the run's open gate ACCEPTS the parked unit's work rather
4146
4277
  * than re-running it (wicked-core `confirm_gate`: a `judge_unavailable` / `qe_reviewer_unavailable`
4147
4278
  * denial on the cursor unit is judged by the operator; an open `review_adjudication` gate lands
4148
4279
  * with carried items). Says what the approve does, or `null` when it re-runs the unit.
4149
4280
  */
4150
- async function approveAcceptsWork(run, ord) {
4281
+ async function approveAcceptsWork(run, ord, gateKind) {
4151
4282
  const unit = run.units.find((u) => u.ord === ord);
4152
4283
  const denial = unit?.denial;
4153
4284
  if (unit?.status === 'rejected' && (denial?.source === 'judge_unavailable' || denial?.source === 'qe_reviewer_unavailable')) {
4154
4285
  return 'is the operator\'s judgment of the work as it stands (no judge could rule)';
4155
4286
  }
4156
- const open = await resolveOpenGate(run.session.id).catch(() => null);
4157
- if (open !== null && open !== 'no-log' && open.gateKind === 'review_adjudication') {
4287
+ if (gateKind === 'review_adjudication') {
4158
4288
  return 'lands the reviewed work with its carried items';
4159
4289
  }
4160
4290
  return null;
@@ -4170,135 +4300,144 @@ runtime = {}) {
4170
4300
  if (!parsed.success) {
4171
4301
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
4172
4302
  }
4173
- const views = await adapter.sessionsDetail();
4174
- const run = views.find((v) => v.session.id === id);
4175
- if (!run)
4176
- return reply.code(404).send({ error: 'Run not found' });
4177
- // Wave 6 (F-7R2-007 root fix): a run PARKED at a gate on a dead seat — the stall watchdog's
4178
- // escalation gate, or a verdict escalation — is reassigned and approved in ONE call. crew#1058:
4179
- // with a named seat it is REASSIGN-THEN-APPROVE. The engine re-seats a parked run's cursor unit
4180
- // without dispatching it (wicked-core#773, `reseat_parked_unit`, core-ts >= 0.13.0) and refuses
4181
- // a seat it cannot honour (an author's seat for a review under a distinct-evaluator contract,
4182
- // a ballot-only seat, a held plan) with the run untouched, so a refusal is a 409 with the gate
4183
- // still open; the approve then dispatches the unit once, on the new seat. The old order
4184
- // (approve, then reassign) approved first, so a refused reassign left the failed seat running
4185
- // again. Without a named seat (the council's pick) the engine needs a running unit to re-route,
4186
- // so that path stays approve-then-reassign. Audited as BOTH a gate decision
4187
- // (`via: 'reassign'`) and a reassign, so the "who approved" trail has no side door.
4188
- // A steering-author propose gate is NOT approvable this way: its approve LANDS the proposal
4189
- // through `POST /runs/:id/gate` (crew#388), and a reassign is not a review of the rules.
4190
- const gated = run.session.status === 'awaiting_human';
4191
- if (run.session.status !== 'executing' && !gated) {
4192
- return reply.code(409).send({
4193
- error: `run ${id} is ${run.session.status}, not executing — only an executing run has a cursor unit to reassign`,
4194
- });
4195
- }
4196
- if (gated && typeof adapter.confirmGate !== 'function') {
4197
- // A partial adapter (a directly-driven route set) cannot approve the gate on the caller's
4198
- // behalf — say so by name, never a TypeError dressed as a refusal.
4199
- return reply.code(409).send({
4200
- error: `run ${id} is awaiting_human, not executing — the engine reassigns only an executing run, and ` +
4201
- `this build cannot approve the gate on your behalf (no confirmGate); approve via POST /runs/${id}/gate, then reassign`,
4202
- });
4203
- }
4204
- if (gated && isSteeringAuthorRun(run)) {
4205
- return reply.code(409).send({
4206
- error: `run ${id} is awaiting a steering-author propose gate — approve or reject it via POST /runs/${id}/gate (the approve lands the proposal); reassign is not a review of the rules`,
4207
- });
4208
- }
4209
- const requestedCli = parsed.data.cli;
4210
- if (requestedCli !== undefined) {
4211
- const pool = Array.isArray(run.session.clis) ? run.session.clis : [];
4212
- if (!pool.includes(requestedCli)) {
4213
- return reply.code(400).send({
4214
- error: `cli "${requestedCli}" is not in this run's seat pool (${pool.join(', ') || 'none known'})`,
4215
- });
4216
- }
4303
+ // One reassign per run at a time, refused rather than queued (crew#1083 round 7), then the gate
4304
+ // decision lock every confirming path shares, held from this read of the run through the re-seat
4305
+ // and the confirm (crew#1083 round 5).
4306
+ if (reassignsInFlight.has(id)) {
4307
+ return reply.code(409).send({ error: `a reassign of run ${id} is already in flight. Nothing was changed.` });
4217
4308
  }
4218
- // A gated reassign APPROVES the gate first, so it passes the same delivery checks (freeze,
4219
- // wicked-core#850 EX-03) when the gate holds the deliver unit (codex r1 on the EX-03 PR).
4220
- if (gated) {
4221
- const gatedUnit = await gatedUnitOf(run, undefined);
4222
- if (gatedUnit !== undefined && isDeliverUnit(gatedUnit)) {
4223
- const refused = await deliverApprovalRefusal(run, gatedUnit);
4224
- if ('refusal' in refused)
4225
- return reply.code(refused.refusal.code).send(refused.refusal.body);
4226
- }
4227
- }
4228
- const cursor = resolveCursorUnit(run);
4229
- const ord = cursor?.ord ?? run.session.unit_ix;
4230
- const approve = async () => {
4231
- if (typeof adapter.confirmGate !== 'function')
4232
- return; // refused above for a gated run
4233
- const who = actorOf(req);
4234
- adapter.recordGateActor?.(id, `${who.id} (${who.kind})`);
4235
- const status = await adapter.confirmGate(id, true);
4236
- audit.record('gate.decided', actorOf(req), {
4237
- runId: id,
4238
- detail: { approve: true, via: 'reassign', status },
4239
- });
4240
- };
4241
- const reassigned = (approved) => {
4242
- audit.record('run.reassigned', actorOf(req), {
4243
- runId: id,
4244
- detail: { ord, ...(requestedCli !== undefined ? { cli: requestedCli } : {}), ...(gated ? { approved } : {}) },
4245
- });
4246
- };
4247
- if (gated && requestedCli !== undefined) {
4248
- // codex r1 on #1060: an approve that ACCEPTS the parked work instead of re-running the unit —
4249
- // a `judge_unavailable` / `qe_reviewer_unavailable` denial (the operator judges, the unit
4250
- // counts) or a `review_adjudication` gate (lands with carried items) — must not follow a
4251
- // re-seat: the new seat would be recorded on work it never did, and never run.
4252
- const accepts = await approveAcceptsWork(run, ord);
4253
- if (accepts !== null) {
4254
- return reply.code(409).send({
4255
- error: `run ${id} is parked at a gate whose approve ${accepts}, so a reassign would not run unit ${ord} on '${requestedCli}' — approve, send back or stop it via POST /runs/${id}/gate. Nothing was changed.`,
4256
- });
4257
- }
4258
- // codex r1 on #1060: the re-seat and the approve are two engine commands; a second reassign of
4259
- // the same run between them would dispatch a seat neither response names.
4260
- if (gatedReassigns.has(id)) {
4261
- return reply.code(409).send({ error: `a reassign of run ${id} is already in flight. Nothing was changed.` });
4262
- }
4263
- gatedReassigns.add(id);
4264
- try {
4265
- try {
4266
- await adapter.reassignUnit(id, ord, requestedCli);
4309
+ reassignsInFlight.add(id);
4310
+ try {
4311
+ return await decideExclusively(id, async () => {
4312
+ const views = await adapter.sessionsDetail();
4313
+ const run = views.find((v) => v.session.id === id);
4314
+ if (!run)
4315
+ return reply.code(404).send({ error: 'Run not found' });
4316
+ // Wave 6 (F-7R2-007 root fix): a run PARKED at a gate on a dead seat — the stall watchdog's
4317
+ // escalation gate, or a verdict escalation — is reassigned and approved in ONE call. crew#1058:
4318
+ // with a named seat it is REASSIGN-THEN-APPROVE. The engine re-seats a parked run's cursor unit
4319
+ // without dispatching it (wicked-core#773, `reseat_parked_unit`, core-ts >= 0.13.0) and refuses
4320
+ // a seat it cannot honour (an author's seat for a review under a distinct-evaluator contract,
4321
+ // a ballot-only seat, a held plan) with the run untouched, so a refusal is a 409 with the gate
4322
+ // still open; the approve then dispatches the unit once, on the new seat. The old order
4323
+ // (approve, then reassign) approved first, so a refused reassign left the failed seat running
4324
+ // again. Without a named seat (the council's pick) the engine needs a running unit to re-route,
4325
+ // so that path stays approve-then-reassign. Audited as BOTH a gate decision
4326
+ // (`via: 'reassign'`) and a reassign, so the "who approved" trail has no side door.
4327
+ // A steering-author propose gate is NOT approvable this way: its approve LANDS the proposal
4328
+ // through `POST /runs/:id/gate` (crew#388), and a reassign is not a review of the rules.
4329
+ const gated = run.session.status === 'awaiting_human';
4330
+ if (run.session.status !== 'executing' && !gated) {
4331
+ return reply.code(409).send({
4332
+ error: `run ${id} is ${run.session.status}, not executing — only an executing run has a cursor unit to reassign`,
4333
+ });
4267
4334
  }
4268
- catch (err) {
4269
- // The engine refused the seat with the run untouched: nothing was approved.
4270
- return reply.code(409).send({ error: `${message(err)}. Nothing was approved; the gate is still open.` });
4335
+ if (gated && typeof adapter.confirmGate !== 'function') {
4336
+ // A partial adapter (a directly-driven route set) cannot approve the gate on the caller's
4337
+ // behalf — say so by name, never a TypeError dressed as a refusal.
4338
+ return reply.code(409).send({
4339
+ error: `run ${id} is awaiting_human, not executing — the engine reassigns only an executing run, and ` +
4340
+ `this build cannot approve the gate on your behalf (no confirmGate); approve via POST /runs/${id}/gate, then reassign`,
4341
+ });
4342
+ }
4343
+ if (gated && isSteeringAuthorRun(run)) {
4344
+ return reply.code(409).send({
4345
+ error: `run ${id} is awaiting a steering-author propose gate — approve or reject it via POST /runs/${id}/gate (the approve lands the proposal); reassign is not a review of the rules`,
4346
+ });
4347
+ }
4348
+ const requestedCli = parsed.data.cli;
4349
+ if (requestedCli !== undefined) {
4350
+ const pool = Array.isArray(run.session.clis) ? run.session.clis : [];
4351
+ if (!pool.includes(requestedCli)) {
4352
+ return reply.code(400).send({
4353
+ error: `cli "${requestedCli}" is not in this run's seat pool (${pool.join(', ') || 'none known'})`,
4354
+ });
4355
+ }
4356
+ }
4357
+ // A gated reassign APPROVES the gate, so it is judged by the record like every decision and passes
4358
+ // the same delivery checks (freeze, wicked-core#850 EX-03) when the gate holds the deliver unit
4359
+ // (codex r1 on the EX-03 PR).
4360
+ const decided = gated ? await gateForDecision(run, undefined) : undefined;
4361
+ if (decided !== undefined && 'refusal' in decided)
4362
+ return reply.code(decided.refusal.code).send(decided.refusal.body);
4363
+ if (decided !== undefined) {
4364
+ const gatedUnit = decided.gated;
4365
+ if (gatedUnit !== undefined && isDeliverUnit(gatedUnit)) {
4366
+ const refused = await deliverApprovalRefusal(run, gatedUnit);
4367
+ if ('refusal' in refused)
4368
+ return reply.code(refused.refusal.code).send(refused.refusal.body);
4369
+ }
4370
+ }
4371
+ const cursor = resolveCursorUnit(run);
4372
+ const ord = cursor?.ord ?? run.session.unit_ix;
4373
+ const approve = async () => {
4374
+ if (typeof adapter.confirmGate !== 'function')
4375
+ return; // refused above for a gated run
4376
+ const who = actorOf(req);
4377
+ adapter.recordGateActor?.(id, `${who.id} (${who.kind})`);
4378
+ const status = await adapter.confirmGate(id, true);
4379
+ audit.record('gate.decided', actorOf(req), {
4380
+ runId: id,
4381
+ detail: { approve: true, via: 'reassign', status },
4382
+ });
4383
+ };
4384
+ const reassigned = (approved) => {
4385
+ audit.record('run.reassigned', actorOf(req), {
4386
+ runId: id,
4387
+ detail: { ord, ...(requestedCli !== undefined ? { cli: requestedCli } : {}), ...(gated ? { approved } : {}) },
4388
+ });
4389
+ };
4390
+ if (gated && requestedCli !== undefined) {
4391
+ // codex r1 on #1060: an approve that ACCEPTS the parked work instead of re-running the unit —
4392
+ // a `judge_unavailable` / `qe_reviewer_unavailable` denial (the operator judges, the unit
4393
+ // counts) or a `review_adjudication` gate (lands with carried items) — must not follow a
4394
+ // re-seat: the new seat would be recorded on work it never did, and never run.
4395
+ const accepts = await approveAcceptsWork(run, ord, decided?.gateKind);
4396
+ if (accepts !== null) {
4397
+ return reply.code(409).send({
4398
+ error: `run ${id} is parked at a gate whose approve ${accepts}, so a reassign would not run unit ${ord} on '${requestedCli}' — approve, send back or stop it via POST /runs/${id}/gate. Nothing was changed.`,
4399
+ });
4400
+ }
4401
+ // The re-seat and the approve are two engine commands; the in-flight refusal and the lock this
4402
+ // runs under keep a second reassign (or any decision) of the run from landing between them.
4403
+ try {
4404
+ await adapter.reassignUnit(id, ord, requestedCli);
4405
+ }
4406
+ catch (err) {
4407
+ // The engine refused the seat with the run untouched: nothing was approved.
4408
+ return reply.code(409).send({ error: `${message(err)}. Nothing was approved; the gate is still open.` });
4409
+ }
4410
+ try {
4411
+ await approve();
4412
+ }
4413
+ catch (err) {
4414
+ reassigned(false);
4415
+ return reply.code(409).send({
4416
+ error: `unit ${ord} was re-seated on ${requestedCli}, but approving the gate failed: ${message(err)} — read the run's gate before answering again`,
4417
+ });
4418
+ }
4419
+ reassigned(true);
4420
+ return reply.send({ status: 'ok', ord, cli: requestedCli, approved: true });
4271
4421
  }
4272
4422
  try {
4273
- await approve();
4423
+ if (gated)
4424
+ await approve();
4425
+ await adapter.reassignUnit(id, ord, requestedCli ?? null);
4426
+ reassigned(true);
4427
+ return reply.send({
4428
+ status: 'ok',
4429
+ ord,
4430
+ ...(requestedCli !== undefined ? { cli: requestedCli } : {}),
4431
+ ...(gated ? { approved: true } : {}),
4432
+ });
4274
4433
  }
4275
4434
  catch (err) {
4276
- reassigned(false);
4277
- return reply.code(409).send({
4278
- error: `unit ${ord} was re-seated on ${requestedCli}, but approving the gate failed: ${message(err)} — read the run's gate before answering again`,
4279
- });
4435
+ return reply.code(409).send({ error: message(err) });
4280
4436
  }
4281
- reassigned(true);
4282
- return reply.send({ status: 'ok', ord, cli: requestedCli, approved: true });
4283
- }
4284
- finally {
4285
- gatedReassigns.delete(id);
4286
- }
4287
- }
4288
- try {
4289
- if (gated)
4290
- await approve();
4291
- await adapter.reassignUnit(id, ord, requestedCli ?? null);
4292
- reassigned(true);
4293
- return reply.send({
4294
- status: 'ok',
4295
- ord,
4296
- ...(requestedCli !== undefined ? { cli: requestedCli } : {}),
4297
- ...(gated ? { approved: true } : {}),
4298
4437
  });
4299
4438
  }
4300
- catch (err) {
4301
- return reply.code(409).send({ error: message(err) });
4439
+ finally {
4440
+ reassignsInFlight.delete(id);
4302
4441
  }
4303
4442
  });
4304
4443
  // The gate prompt for a paused run, so a fresh browser can render the gate after a late join.
@@ -4314,9 +4453,31 @@ runtime = {}) {
4314
4453
  const run = (await adapter.sessionsDetail()).find((v) => v.session.id === id);
4315
4454
  return receiptOf(run?.units?.find((u) => u.ord === ord));
4316
4455
  };
4456
+ // The record judges the cache (crew#1083 round 7): rows that disagree on the ord are 409
4457
+ // `gate_unknown` whether or not something is cached, and a cached entry is served — for its
4458
+ // presentation fields (prompt, choices, recommended) — only when its ord is the record's open
4459
+ // row's. With no open row the gate predates the engine's table and its record is the log
4460
+ // (FINDING-051): the entry is served while the run is still parked, so a restarted daemon does
4461
+ // not replay the log on every poll, and dropped once it is not (a late frame for an answered
4462
+ // gate). Any entry not served is dropped and the gate read from the record. Only an engine that
4463
+ // keeps no rows serves the cache unjudged.
4464
+ const recorded = await recordedOpenGate(id);
4465
+ if (recorded?.state === 'ambiguous') {
4466
+ return reply.code(409).send({
4467
+ error: `Gate unknown: the engine's record shows gates open before units ${recorded.ords.join(' and ')} on this run, so the daemon cannot tell which one is open.`,
4468
+ code: 'gate_unknown',
4469
+ });
4470
+ }
4317
4471
  const cached = gateCache.get(id);
4318
- if (cached)
4319
- return { runId: id, ...cached, assurance: await receiptAt(cached.ord) };
4472
+ if (cached) {
4473
+ const servable = recorded === null
4474
+ || (recorded.state === 'open' && cached.ord === recorded.ord)
4475
+ || (recorded.state === 'none'
4476
+ && (await adapter.sessionsDetail()).find((v) => v.session.id === id)?.session.status === 'awaiting_human');
4477
+ if (servable)
4478
+ return { runId: id, ...cached, assurance: await receiptAt(cached.ord) };
4479
+ gateCache.forget(id);
4480
+ }
4320
4481
  // A miss is not evidence of anything on its own, so ask the run what it is doing before paying
4321
4482
  // to replay it. Only `awaiting_human` can have an open gate, and that answer is definitive:
4322
4483
  // every other status is a 404 that needs no log at all — which is also the overwhelmingly
@@ -4338,7 +4499,39 @@ runtime = {}) {
4338
4499
  // daemon restart reads it back instead of replaying the log; the replay stays the FALLBACK for
4339
4500
  // runs parked before the engine grew the table. `resolveOpenGate` is that one resolution, shared
4340
4501
  // with the POST route's `ord` check.
4341
- const open = await resolveOpenGate(id);
4502
+ let open = await resolveOpenGate(id);
4503
+ // The record judges what the resolver returns too (crew#1083 final review): a stale frame folded
4504
+ // into the cache after GET's record read — during the status read, before any hydration began,
4505
+ // so no revision guard ran — is a cache hit for the resolver. When the answer disagrees with
4506
+ // that read, the record is read once more (it may have moved on, legitimately, meanwhile): an
4507
+ // answer that matches it is served; otherwise the entry is dropped and the gate resolved once
4508
+ // more from the record, and if that still disagrees nothing verified is served.
4509
+ if (recorded?.state === 'open' && isOpenGate(open) && open.ord !== recorded.ord) {
4510
+ const now = await recordedOpenGate(id);
4511
+ const matchesNow = (o) => isOpenGate(o) && now?.state === 'open' && o.ord === now.ord;
4512
+ if (!matchesNow(open)) {
4513
+ gateCache.forget(id);
4514
+ open = await resolveOpenGate(id);
4515
+ if (isOpenGate(open) && !matchesNow(open))
4516
+ open = 'unstable';
4517
+ }
4518
+ }
4519
+ if (open === 'ambiguous') {
4520
+ // The engine's open gate rows disagree on the ord (crew#1083 round 5): say so, as a decision
4521
+ // naming either gate is told — never pick one.
4522
+ return reply.code(409).send({
4523
+ error: 'Gate unknown: the engine\'s record shows gates open before more than one unit on this run, so the daemon cannot tell which one is open.',
4524
+ code: 'gate_unknown',
4525
+ });
4526
+ }
4527
+ if (open === 'unstable') {
4528
+ // Live gate events kept landing while the record was read, so nothing was verified
4529
+ // (crew#1083 round 3). Retryable: the next read sees a quieter run.
4530
+ return reply.code(503).send({
4531
+ error: 'Gate unstable: live gate events kept landing while the daemon read this run\'s open gate — retry.',
4532
+ code: 'gate_unstable',
4533
+ });
4534
+ }
4342
4535
  if (open === 'no-log') {
4343
4536
  // Now — and only now — 503 is the honest answer: this run really is holding for a human, and
4344
4537
  // this build cannot say what it is asking. Distinct cause, distinct message; answering "no
@@ -6577,7 +6770,7 @@ runtime = {}) {
6577
6770
  registerPresetRoutes(app, adapter, security);
6578
6771
  // ── Team (DES-TEAMING-002 T8) — reads and commands over the engine; the live stream is the
6579
6772
  // teamEvent relay (team/ws-relay.ts). Crew publishes no team fact (§4.0).
6580
- registerTeamRoutes(app, adapter, security);
6773
+ registerTeamRoutes(app, adapter, security, { exclusive: decideExclusively, judge: gateForDecision });
6581
6774
  // ── Campaigns (crew#342 + TH-9) — the engine's durable Run-DAG scheduler over REST ──────────
6582
6775
  // Progress streams as campaign* CoreEvents on the existing allowlist-free /ws relay; these
6583
6776
  // routes are launch/resume/cancel + the reads a scoreboard builds from. The GET routes join