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.
- package/dist/api/gate-cache.d.ts +98 -17
- package/dist/api/gate-cache.d.ts.map +1 -1
- package/dist/api/gate-cache.js +136 -25
- package/dist/api/gate-cache.js.map +1 -1
- package/dist/api/recording.d.ts +3 -2
- package/dist/api/recording.d.ts.map +1 -1
- package/dist/api/recording.js +13 -1
- package/dist/api/recording.js.map +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +537 -344
- package/dist/api/routes.js.map +1 -1
- package/dist/api/seat-work-default.d.ts +22 -0
- package/dist/api/seat-work-default.d.ts.map +1 -0
- package/dist/api/seat-work-default.js +91 -0
- package/dist/api/seat-work-default.js.map +1 -0
- package/dist/api/server.js +1 -1
- package/dist/api/server.js.map +1 -1
- package/dist/studio/assets/{index-BhpoNOI0.js → index-CK-ppFAd.js} +218 -217
- package/dist/studio/assets/index-Chzb3FOa.css +32 -0
- package/dist/studio/editors/wicked-deck/editor.json +1 -1
- package/dist/studio/editors/wicked-deck/index.html +1 -1
- package/dist/studio/editors/wicked-doc/editor.json +1 -1
- package/dist/studio/editors/wicked-doc/index.html +1 -1
- package/dist/studio/editors/wicked-page/editor.json +1 -1
- package/dist/studio/editors/wicked-page/index.html +1 -1
- package/dist/studio/editors/wicked-video/editor.json +1 -1
- package/dist/studio/editors/wicked-video/index.html +1 -1
- package/dist/studio/index.html +2 -2
- package/dist/studio/testid-inventory.json +87 -27
- package/dist/team/routes.d.ts +11 -1
- package/dist/team/routes.d.ts.map +1 -1
- package/dist/team/routes.js +58 -48
- package/dist/team/routes.js.map +1 -1
- package/endpoint-manifest.json +1 -1
- package/package.json +4 -3
- package/dist/studio/assets/index-BEFOUR6c.css +0 -32
package/dist/api/routes.js
CHANGED
|
@@ -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
|
-
|
|
3346
|
-
|
|
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,
|
|
3813
|
-
*
|
|
3814
|
-
*
|
|
3815
|
-
*
|
|
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
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
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
|
|
3832
|
-
|
|
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
|
|
3890
|
+
return replayOpenGate(id, events) ?? null;
|
|
3846
3891
|
}
|
|
3847
|
-
/**
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
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
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
if (
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
const
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
? `run ${id} is
|
|
4062
|
-
: state.delivery === '
|
|
4063
|
-
? `run ${id} is already completed
|
|
4064
|
-
: state.delivery === '
|
|
4065
|
-
? `run ${id} is already completed
|
|
4066
|
-
:
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
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
|
-
|
|
4087
|
-
const
|
|
4088
|
-
|
|
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
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
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
|
-
/**
|
|
4143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
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
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
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
|
-
|
|
4269
|
-
//
|
|
4270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|