wicked-crew 0.15.2 → 0.15.3

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