@ctrl-spc/cs 0.7.15 → 0.7.16
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/agents.js +175 -1
- package/dist/codex-home.js +14 -11
- package/dist/companion-ui.js +24 -4
- package/dist/companion.js +33 -25
- package/dist/config.js +147 -16
- package/dist/daemon-lifecycle.js +39 -12
- package/dist/daemon-processes.js +123 -19
- package/dist/failure-reason.js +76 -16
- package/dist/index.js +6 -3
- package/dist/login.js +6 -8
- package/dist/mcp.js +55 -56
- package/dist/orchestrator.js +322 -197
- package/dist/panel3/coordinator.js +3 -1
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/run.js +220 -84
- package/dist/panel3/spawn.js +26 -12
- package/dist/panel3/tools.js +5 -0
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +122 -145
- package/dist/supabase.js +141 -39
- package/package.json +1 -1
package/dist/orchestrator.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { executionIdentity } from './agents.js';
|
|
2
|
+
import { CodexHomeFailure } from './codex-home.js';
|
|
1
3
|
import { execFile, spawn as spawnChild } from 'node:child_process';
|
|
2
4
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { beginOwnedClaim, ownedWorkAllowed, hasOwnedWorkContext, ownedWorkerClaimedAt, ownedWorkerExecutionHeld, reserveOwnedWork, legacyHeldTodoIds, interruptionReceipts, acknowledgeInterruption, legacySnapshotNeeded, saveLegacySnapshot, legacyTodoHolds, releaseLegacyTodoHold, recordExitedInterruption, beginRpcClaim, deferRpcClaim, pendingRpcClaims, recordRpcClaims, finishRpcClaims } from './daemon-processes.js';
|
|
5
|
+
import { beginOwnedClaim, ownedWorkAllowed, hasOwnedWorkContext, pendingTerminalOutcomes, acknowledgeTerminalOutcome, updateTerminalOutcome, ownedWorkerClaimedAt, ownedWorkerExecutionHeld, reserveOwnedWork, legacyHeldTodoIds, interruptionReceipts, acknowledgeInterruption, legacySnapshotNeeded, saveLegacySnapshot, legacyTodoHolds, releaseLegacyTodoHold, recordExitedInterruption, beginRpcClaim, deferRpcClaim, pendingRpcClaims, recordRpcClaims, finishRpcClaims, recoveredRpcClaims, pendingRpcReference, retireRpcReference } from './daemon-processes.js';
|
|
4
6
|
import { promisify } from 'node:util';
|
|
5
7
|
import { agentPath, detectAgents } from './agents.js';
|
|
6
8
|
/* 18a Slice 7: the two Windows spawn rules, in one module so a caller cannot
|
|
@@ -9,7 +11,7 @@ import { agentPath, detectAgents } from './agents.js';
|
|
|
9
11
|
import { killTree, needsShell, processIsAlive, quoteForCmd, spawnOwnedProcess, releaseOwnedProcess } from './win-shell.js';
|
|
10
12
|
export { quoteForCmd } from './win-shell.js';
|
|
11
13
|
import { ORCHESTRATOR_MAX_CONCURRENT, ORCHESTRATOR_SPAWN_TIMEOUT_MS } from './env.js';
|
|
12
|
-
import { agentDisplayName,
|
|
14
|
+
import { agentDisplayName, nativeFailureKind, failureMessage } from './failure-reason.js';
|
|
13
15
|
/* 18c SLICE 9 (GAP 15): the two request-scoped releases. They live in mcp.ts
|
|
14
16
|
beside the session-end routes and the reservations table's other writers, so
|
|
15
17
|
there is ONE place that knows how a lease is let go, rather than two that can
|
|
@@ -200,7 +202,7 @@ export async function claimNextTodo(client, machineId, agent,
|
|
|
200
202
|
because the claim must stay ONE statement (see below), and because a
|
|
201
203
|
blocked unit is the OLDEST claimable row: released and re-claimed it would
|
|
202
204
|
be picked first every tick, starving everything behind it. */
|
|
203
|
-
skipIds = []) {
|
|
205
|
+
skipIds = [], recoveryOnly = false) {
|
|
204
206
|
/* THROUGH AN RPC, NOT PostgREST — and this is not a style preference, it is
|
|
205
207
|
the fix for a bug that made the whole slice inert.
|
|
206
208
|
|
|
@@ -229,7 +231,7 @@ skipIds = []) {
|
|
|
229
231
|
p_machine_id: machineId,
|
|
230
232
|
p_agent: agent,
|
|
231
233
|
p_skip_ids: skipIds,
|
|
232
|
-
});
|
|
234
|
+
}, recoveryOnly);
|
|
233
235
|
if (error)
|
|
234
236
|
throw error;
|
|
235
237
|
/* 18b Slice 2: the claim also carries the request's OWN project (id and name),
|
|
@@ -493,7 +495,7 @@ claimedAt) {
|
|
|
493
495
|
if (error)
|
|
494
496
|
throw error;
|
|
495
497
|
if (!written?.length)
|
|
496
|
-
return;
|
|
498
|
+
return false;
|
|
497
499
|
/* 18c SLICE 1 — CLOSE OUT THE LAST ACTIVITY LINE.
|
|
498
500
|
*
|
|
499
501
|
* report_activity only ever flips the PREVIOUS 'doing' row to 'done' when a
|
|
@@ -517,6 +519,7 @@ claimedAt) {
|
|
|
517
519
|
catch (err) {
|
|
518
520
|
console.warn(`answerTodo: closing out the last activity line failed: ${err.message}`);
|
|
519
521
|
}
|
|
522
|
+
return true;
|
|
520
523
|
}
|
|
521
524
|
/**
|
|
522
525
|
* !Cleanup PHASE 6 (I15) — DID THIS WORKER ASK SOMETHING AND EXIT?
|
|
@@ -542,7 +545,7 @@ claimedAt) {
|
|
|
542
545
|
* a card asking the user for something that does not exist, unclearable except
|
|
543
546
|
* by Stop. A wrong `done` is recoverable by Retry; a phantom question is not.
|
|
544
547
|
*/
|
|
545
|
-
export async function todoHasOpenAsk(client, todoId, warn) {
|
|
548
|
+
export async function todoHasOpenAsk(client, todoId, warn, strict = false) {
|
|
546
549
|
try {
|
|
547
550
|
const { data, error } = await client
|
|
548
551
|
.from('cliv2_todo_asks')
|
|
@@ -554,11 +557,13 @@ export async function todoHasOpenAsk(client, todoId, warn) {
|
|
|
554
557
|
throw new Error(error.message);
|
|
555
558
|
if ((data ?? []).length > 0)
|
|
556
559
|
return true;
|
|
557
|
-
if (await todoHasOpenDecision(client, todoId, warn))
|
|
560
|
+
if (await todoHasOpenDecision(client, todoId, warn, strict))
|
|
558
561
|
return true;
|
|
559
|
-
return await todoHasOpenBlockedNotice(client, todoId, warn);
|
|
562
|
+
return await todoHasOpenBlockedNotice(client, todoId, warn, strict);
|
|
560
563
|
}
|
|
561
564
|
catch (err) {
|
|
565
|
+
if (strict)
|
|
566
|
+
throw err;
|
|
562
567
|
warn(`[orchestrator] could not check for open questions: ${err.message}`);
|
|
563
568
|
return false;
|
|
564
569
|
}
|
|
@@ -582,7 +587,7 @@ export async function todoHasOpenAsk(client, todoId, warn) {
|
|
|
582
587
|
* Best-effort and false on error, like its two siblings, and for the same
|
|
583
588
|
* reason: a wrong `done` is recoverable by Retry, a phantom park is not.
|
|
584
589
|
*/
|
|
585
|
-
export async function todoHasOpenBlockedNotice(client, todoId, warn) {
|
|
590
|
+
export async function todoHasOpenBlockedNotice(client, todoId, warn, strict = false) {
|
|
586
591
|
try {
|
|
587
592
|
const { data, error } = await client
|
|
588
593
|
.from('cliv2_blocked_notices')
|
|
@@ -595,6 +600,8 @@ export async function todoHasOpenBlockedNotice(client, todoId, warn) {
|
|
|
595
600
|
return (data ?? []).length > 0;
|
|
596
601
|
}
|
|
597
602
|
catch (err) {
|
|
603
|
+
if (strict)
|
|
604
|
+
throw err;
|
|
598
605
|
warn(`[orchestrator] could not check for an open stop: ${err.message}`);
|
|
599
606
|
return false;
|
|
600
607
|
}
|
|
@@ -635,7 +642,7 @@ const PERMISSION_CATEGORY = 'cliv2_permission';
|
|
|
635
642
|
* BEST-EFFORT, like every read in the tick. A failure returns false, which costs
|
|
636
643
|
* a wrongly-finished card rather than a lost run.
|
|
637
644
|
*/
|
|
638
|
-
export async function todoHasOpenDecision(client, todoId, warn) {
|
|
645
|
+
export async function todoHasOpenDecision(client, todoId, warn, strict = false) {
|
|
639
646
|
try {
|
|
640
647
|
const { data: links, error: linkError } = await client
|
|
641
648
|
.from('cliv2_loose_todo_links')
|
|
@@ -667,6 +674,8 @@ export async function todoHasOpenDecision(client, todoId, warn) {
|
|
|
667
674
|
return (data ?? []).length > 0;
|
|
668
675
|
}
|
|
669
676
|
catch (err) {
|
|
677
|
+
if (strict)
|
|
678
|
+
throw err;
|
|
670
679
|
warn(`[orchestrator] could not check the work item's questions: ${err.message}`);
|
|
671
680
|
return false;
|
|
672
681
|
}
|
|
@@ -779,7 +788,7 @@ export async function todoRunLeftATrace(client, todoId, warn,
|
|
|
779
788
|
* same answer for the scope gate and resolving it twice would let the two
|
|
780
789
|
* disagree about which item this run is for. Null for a panel request with
|
|
781
790
|
* no work item, which is the greenfield case. */
|
|
782
|
-
workItemId = null) {
|
|
791
|
+
workItemId = null, strict = false) {
|
|
783
792
|
try {
|
|
784
793
|
/* ANY ONE IS ENOUGH, so the cheapest and most likely is asked first and the
|
|
785
794
|
rest are skipped once one answers yes. A session is what the guidance
|
|
@@ -828,6 +837,8 @@ workItemId = null) {
|
|
|
828
837
|
return (dropped.data?.dropped_activity ?? 0) > 0;
|
|
829
838
|
}
|
|
830
839
|
catch (err) {
|
|
840
|
+
if (strict)
|
|
841
|
+
throw err;
|
|
831
842
|
/* See the block comment: towards `done`, so a transient read failure never
|
|
832
843
|
discards a run that really did the work. */
|
|
833
844
|
warn(`[orchestrator] could not check whether the run recorded anything: ${err.message}`);
|
|
@@ -866,9 +877,10 @@ claimedAt) {
|
|
|
866
877
|
.update({ answer: trimmed || null, state: 'needs-input' })
|
|
867
878
|
.eq('id', todoId)
|
|
868
879
|
.in('state', ['working', 'needs-input']);
|
|
869
|
-
const { error } = await (claimedAt ? write.eq('claimed_at', claimedAt) : write);
|
|
880
|
+
const { data, error } = await (claimedAt ? write.eq('claimed_at', claimedAt) : write).select('id');
|
|
870
881
|
if (error)
|
|
871
882
|
throw error;
|
|
883
|
+
return !!data?.length;
|
|
872
884
|
}
|
|
873
885
|
/**
|
|
874
886
|
* !Cleanup PHASE 6 (I17) — HAS THE USER STOPPED THIS?
|
|
@@ -950,6 +962,41 @@ export const MAX_ATTEMPTS = 3;
|
|
|
950
962
|
* so there is one count: a second read could land after another process had
|
|
951
963
|
* touched the row, and a wait computed from a stale count is a wait the card's
|
|
952
964
|
* own countdown would disagree with. */
|
|
965
|
+
/** The existing terminal write, also used for a nonretryable native refusal. */
|
|
966
|
+
async function failClaimedTodo(client, todoId, reason, expectedClaim, count) {
|
|
967
|
+
let attempts = count ?? 0;
|
|
968
|
+
if (count === undefined) {
|
|
969
|
+
const { data, error } = await client.from('cliv2_loose_todos').select('attempts,claimed_at').eq('id', todoId).maybeSingle();
|
|
970
|
+
if (error)
|
|
971
|
+
throw error;
|
|
972
|
+
if (!data || data.claimed_at !== expectedClaim)
|
|
973
|
+
return { fate: 'stale', attempts: data?.attempts ?? 0 };
|
|
974
|
+
attempts = Number(data.attempts ?? 0) + 1;
|
|
975
|
+
}
|
|
976
|
+
let failure = client
|
|
977
|
+
.from('cliv2_loose_todos')
|
|
978
|
+
.update({
|
|
979
|
+
state: 'failed',
|
|
980
|
+
attempts,
|
|
981
|
+
stopped_reason: reason.trim() || 'the work failed repeatedly',
|
|
982
|
+
claimed_machine_id: null,
|
|
983
|
+
claimed_agent: null,
|
|
984
|
+
claimed_at: null,
|
|
985
|
+
/* 18a SLICE 6: cleared at the bound. A wait on a terminal row would be
|
|
986
|
+
read by nothing and would outlive the row's own meaning; the only
|
|
987
|
+
route back is Retry, which means now. */
|
|
988
|
+
retry_after: null,
|
|
989
|
+
})
|
|
990
|
+
.eq('id', todoId)
|
|
991
|
+
.in('state', ['working', 'needs-input']);
|
|
992
|
+
failure = expectedClaim ? failure.eq('claimed_at', expectedClaim) : failure.is('claimed_at', null);
|
|
993
|
+
const { data: failed, error: failError } = await failure.select('id');
|
|
994
|
+
if (failError)
|
|
995
|
+
throw failError;
|
|
996
|
+
if (!failed?.length)
|
|
997
|
+
return { fate: 'stale', attempts: attempts - 1 };
|
|
998
|
+
return { fate: 'failed', attempts };
|
|
999
|
+
}
|
|
953
1000
|
export async function releaseOrFail(client, todoId, reason,
|
|
954
1001
|
/** 18a Slice 6: injectable so a test can place the wait exactly. */
|
|
955
1002
|
nowMs = Date.now(),
|
|
@@ -974,31 +1021,8 @@ claimedAt) {
|
|
|
974
1021
|
}
|
|
975
1022
|
const attempts = (current?.attempts ?? 0) + 1;
|
|
976
1023
|
const expectedClaim = claimedAt ?? current?.claimed_at ?? null;
|
|
977
|
-
if (attempts >= MAX_ATTEMPTS)
|
|
978
|
-
|
|
979
|
-
.from('cliv2_loose_todos')
|
|
980
|
-
.update({
|
|
981
|
-
state: 'failed',
|
|
982
|
-
attempts,
|
|
983
|
-
stopped_reason: reason.trim() || 'the work failed repeatedly',
|
|
984
|
-
claimed_machine_id: null,
|
|
985
|
-
claimed_agent: null,
|
|
986
|
-
claimed_at: null,
|
|
987
|
-
/* 18a SLICE 6: cleared at the bound. A wait on a terminal row would be
|
|
988
|
-
read by nothing and would outlive the row's own meaning; the only
|
|
989
|
-
route back is Retry, which means now. */
|
|
990
|
-
retry_after: null,
|
|
991
|
-
})
|
|
992
|
-
.eq('id', todoId)
|
|
993
|
-
.in('state', ['working', 'needs-input']);
|
|
994
|
-
failure = expectedClaim ? failure.eq('claimed_at', expectedClaim) : failure.is('claimed_at', null);
|
|
995
|
-
const { data: failed, error: failError } = await failure.select('id');
|
|
996
|
-
if (failError)
|
|
997
|
-
throw failError;
|
|
998
|
-
if (!failed?.length)
|
|
999
|
-
return { fate: 'stale', attempts: current?.attempts ?? 0 };
|
|
1000
|
-
return { fate: 'failed', attempts };
|
|
1001
|
-
}
|
|
1024
|
+
if (attempts >= MAX_ATTEMPTS)
|
|
1025
|
+
return failClaimedTodo(client, todoId, reason, expectedClaim, attempts);
|
|
1002
1026
|
/* 18a SLICE 6: THE WAIT IS WRITTEN ON THE SAME STATEMENT AS THE COUNT, for
|
|
1003
1027
|
the reason the count is on the same statement as the release: a wait that
|
|
1004
1028
|
can fail on its own is a wait the daemon might not honour, and the failure
|
|
@@ -1270,7 +1294,7 @@ injectedSpawn,
|
|
|
1270
1294
|
* worker's, and injected by the same convention (`deps.resolveContext`) so a
|
|
1271
1295
|
* test can point it somewhere without `resolveClaimContext` growing
|
|
1272
1296
|
* parameters for the suite's sake. */
|
|
1273
|
-
resolveContext = (claim) => resolveClaimContext(client, claim, warn)) {
|
|
1297
|
+
resolveContext = (claim) => resolveClaimContext(client, claim, warn), observeHarness) {
|
|
1274
1298
|
/* ═══ 18k SLICE 10b — NO TOOLS MEANS NO ANSWER, the tick's own rule. ═══
|
|
1275
1299
|
An answerer now holds the worker's toolset, so one started while the tools
|
|
1276
1300
|
server is down would have NONE of it: it could not read the work item, could
|
|
@@ -1315,14 +1339,14 @@ resolveContext = (claim) => resolveClaimContext(client, claim, warn)) {
|
|
|
1315
1339
|
const endClaim = beginOwnedClaim();
|
|
1316
1340
|
state.takeInFlight = true;
|
|
1317
1341
|
try {
|
|
1318
|
-
await takeRunMessagesInner(client, userId, machineId, agents, state, log, warn, spawn, resolveContext);
|
|
1342
|
+
await takeRunMessagesInner(client, userId, machineId, agents, state, log, warn, spawn, resolveContext, observeHarness);
|
|
1319
1343
|
}
|
|
1320
1344
|
finally {
|
|
1321
1345
|
state.takeInFlight = false;
|
|
1322
1346
|
endClaim();
|
|
1323
1347
|
}
|
|
1324
1348
|
}
|
|
1325
|
-
async function takeRunMessagesInner(client, userId, machineId, agents, state, log, warn, spawn, resolveContext) {
|
|
1349
|
+
async function takeRunMessagesInner(client, userId, machineId, agents, state, log, warn, spawn, resolveContext, observeHarness) {
|
|
1326
1350
|
/* ═══ 18k SLICE 10 — A MACHINE ANSWERS ITS OWN RUNS, DESIGNATED OR NOT. ═══
|
|
1327
1351
|
Slice 7 gated this whole function on being the designated orchestrator, and
|
|
1328
1352
|
that is wrong for work already claimed: the user may switch the toolbar to
|
|
@@ -1394,6 +1418,13 @@ async function takeRunMessagesInner(client, userId, machineId, agents, state, lo
|
|
|
1394
1418
|
scoped take with an empty id list, which correctly returns nothing — so this
|
|
1395
1419
|
is a saved round trip every ten seconds on every machine in the fleet that is
|
|
1396
1420
|
not participating, not a safety property. The safety is the scope itself. */
|
|
1421
|
+
if (hasOwnedWorkContext()) {
|
|
1422
|
+
for (const claim of recoveredRpcClaims('cli').filter(row => row.action !== 'cliv2_claim_next_loose_todo')) {
|
|
1423
|
+
for (const ref of cliClaimReferences(claim.action, claim.result))
|
|
1424
|
+
if (ref.surface === 'reply' && pendingRpcReference(ref) && !state.answering.has(ref.todoId) && !ownTodoIds.includes(ref.todoId))
|
|
1425
|
+
ownTodoIds.push(ref.todoId);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1397
1428
|
if (ownTodoIds.length === 0 && !designated)
|
|
1398
1429
|
return;
|
|
1399
1430
|
try {
|
|
@@ -1561,7 +1592,7 @@ async function takeRunMessagesInner(client, userId, machineId, agents, state, lo
|
|
|
1561
1592
|
void answerRunMessages(client, todoId, group.messageIds, { agent: plan.agent, cwd: context.checkout, workingCopyElsewhere: plan.workingCopyElsewhere }, state, spawn, warn, { surface: 'reply', todoId,
|
|
1562
1593
|
messageLeases: taken.filter((row) => row.todo_id === todoId).map((row) => ({ id: row.id, taken_at: row.taken_at })),
|
|
1563
1594
|
grantLeases: granted.filter((row) => row.todo_id === todoId).map((row) => ({ id: row.id, resume_taken_at: row.resume_taken_at })),
|
|
1564
|
-
observedPendingMessageIds: null }, machineId).catch(err => {
|
|
1595
|
+
observedPendingMessageIds: null }, machineId, undefined, observeHarness).catch(err => {
|
|
1565
1596
|
warn(`[orchestrator] could not answer ${shortId(todoId)}: ${err.message}`);
|
|
1566
1597
|
});
|
|
1567
1598
|
}
|
|
@@ -1571,7 +1602,7 @@ async function takeRunMessagesInner(client, userId, machineId, agents, state, lo
|
|
|
1571
1602
|
}
|
|
1572
1603
|
finally {
|
|
1573
1604
|
if (hasOwnedWorkContext())
|
|
1574
|
-
finishRpcClaims(claimOperations);
|
|
1605
|
+
finishRpcClaims(claimOperations, true);
|
|
1575
1606
|
}
|
|
1576
1607
|
}
|
|
1577
1608
|
/**
|
|
@@ -2207,7 +2238,7 @@ plan, state,
|
|
|
2207
2238
|
/** 18k Slice 10b — THE WORKER'S OWN SPAWN SHAPE, not a responder's. There is no
|
|
2208
2239
|
* second adapter any more: this is what `defaultSpawn(mcpServer)` returns, and
|
|
2209
2240
|
* the answerer differs from a dispatch only in passing no `alsoRunningIn`. */
|
|
2210
|
-
spawn, warn, attempt, machineId, owned) {
|
|
2241
|
+
spawn, warn, attempt, machineId, owned, observeHarness) {
|
|
2211
2242
|
/* ═══ 18k SLICE 10b — ONE ANSWER PER RUN AT A TIME. ═══
|
|
2212
2243
|
DEPTH, NOT THE PRIMARY DEFENCE. `takeRunMessagesInner` subtracts
|
|
2213
2244
|
`state.answering` from the scoped take's id list, so a run this machine is
|
|
@@ -2237,8 +2268,9 @@ spawn, warn, attempt, machineId, owned) {
|
|
|
2237
2268
|
owned = hasOwnedWorkContext() ? reserveOwnedWork(attempt, plan.agent) : undefined;
|
|
2238
2269
|
state.answering.add(todoId);
|
|
2239
2270
|
try {
|
|
2240
|
-
const answered = await answerRunMessagesInner(client, todoId, messageIds, plan, state, spawn, warn, attempt, machineId, owned);
|
|
2241
|
-
|
|
2271
|
+
const answered = await answerRunMessagesInner(client, todoId, messageIds, plan, state, spawn, warn, attempt, machineId, owned, observeHarness);
|
|
2272
|
+
if (answered)
|
|
2273
|
+
owned?.complete();
|
|
2242
2274
|
return answered;
|
|
2243
2275
|
}
|
|
2244
2276
|
finally {
|
|
@@ -2246,18 +2278,49 @@ spawn, warn, attempt, machineId, owned) {
|
|
|
2246
2278
|
owned?.finishPreparation();
|
|
2247
2279
|
}
|
|
2248
2280
|
}
|
|
2249
|
-
async function
|
|
2281
|
+
async function settleReplyOutcome(client, machineId, attempt, outcome, onAccepted) {
|
|
2282
|
+
const todoId = attempt.todoId;
|
|
2283
|
+
const finished = await client.rpc('cliv2_finish_reply_attempt', {
|
|
2284
|
+
p_todo_id: todoId, p_machine_id: machineId, p_message_leases: attempt.messageLeases,
|
|
2285
|
+
p_grant_leases: attempt.grantLeases, p_body: outcome.text,
|
|
2286
|
+
...(!outcome.ok && !outcome.stopped ? { p_failure_kind: outcome.failureKind ?? 'unknown' } : {}),
|
|
2287
|
+
});
|
|
2288
|
+
if (finished.error)
|
|
2289
|
+
throw finished.error;
|
|
2290
|
+
const receipt = finished.data?.[0]?.outcome;
|
|
2291
|
+
if (receipt === 'stale')
|
|
2292
|
+
return false;
|
|
2293
|
+
if (receipt === 'failed' || receipt === 'already_failed') {
|
|
2294
|
+
onAccepted?.();
|
|
2295
|
+
return false;
|
|
2296
|
+
}
|
|
2297
|
+
if (receipt !== 'accepted' && receipt !== 'already_settled')
|
|
2298
|
+
throw new Error('The reply acknowledgement was incomplete.');
|
|
2299
|
+
onAccepted?.();
|
|
2300
|
+
if (outcome.stopped && outcome.replyingToClaim)
|
|
2301
|
+
await releaseStoppedTodo(client, todoId, outcome.replyingToClaim);
|
|
2302
|
+
else if (outcome.workerIsLive && outcome.replyingToClaim) {
|
|
2303
|
+
const scheduled = await client.from('cliv2_loose_todos')
|
|
2304
|
+
.update({ claimed_machine_id: null, claimed_agent: null, claimed_at: null })
|
|
2305
|
+
.eq('id', todoId).eq('claimed_at', outcome.replyingToClaim).in('state', ['working', 'needs-input']);
|
|
2306
|
+
if (scheduled.error)
|
|
2307
|
+
throw scheduled.error;
|
|
2308
|
+
}
|
|
2309
|
+
return !outcome.stopped;
|
|
2310
|
+
}
|
|
2311
|
+
async function answerRunMessagesInner(client, todoId, messageIds, plan, state, spawn, warn, attempt, machineId, owned, observeHarness) {
|
|
2250
2312
|
/* ALREADY GIVEN UP ON. Said ONCE, at the bound, and then silently skipped —
|
|
2251
2313
|
the `reportTakeFailed` rule: a line every two minutes forever would bury
|
|
2252
2314
|
every useful one. */
|
|
2253
|
-
const
|
|
2315
|
+
const sourceIds = [...messageIds, ...(attempt?.grantLeases ?? []).map(grant => `grant:${grant.id}`)];
|
|
2316
|
+
const attempts = Math.max(0, ...sourceIds.map(id => state.replyAttempts.get(id) ?? 0));
|
|
2254
2317
|
if (attempts >= MAX_REPLY_ATTEMPTS)
|
|
2255
2318
|
return false;
|
|
2256
2319
|
const fail = (reason) => {
|
|
2257
|
-
for (const id of
|
|
2320
|
+
for (const id of sourceIds) {
|
|
2258
2321
|
state.replyAttempts.set(id, (state.replyAttempts.get(id) ?? 0) + 1);
|
|
2259
2322
|
}
|
|
2260
|
-
const spent = Math.max(...
|
|
2323
|
+
const spent = Math.max(0, ...sourceIds.map(id => state.replyAttempts.get(id) ?? 0));
|
|
2261
2324
|
if (spent >= MAX_REPLY_ATTEMPTS) {
|
|
2262
2325
|
warn(`[orchestrator] gave up answering ${shortId(todoId)} after ${MAX_REPLY_ATTEMPTS} attempts: ${reason}`);
|
|
2263
2326
|
}
|
|
@@ -2335,6 +2398,7 @@ async function answerRunMessagesInner(client, todoId, messageIds, plan, state, s
|
|
|
2335
2398
|
attempt.observedPendingMessageIds = (pending.data ?? []).map((row) => row.id);
|
|
2336
2399
|
owned?.setReference(attempt);
|
|
2337
2400
|
}
|
|
2401
|
+
const harnessIdentity = executionIdentity(plan.agent);
|
|
2338
2402
|
let spawnedPid = null;
|
|
2339
2403
|
const result = await spawn(plan.agent, buildReplyPrompt(record, bodies, { hasCheckout: plan.cwd !== null, workingCopyElsewhere: plan.workingCopyElsewhere }, workerIsLive), undefined, plan.cwd, () => todoWasStopped(client, todoId), (pid) => {
|
|
2340
2404
|
state.livePids.add(pid);
|
|
@@ -2346,40 +2410,34 @@ async function answerRunMessagesInner(client, todoId, messageIds, plan, state, s
|
|
|
2346
2410
|
state.livePids.delete(spawnedPid);
|
|
2347
2411
|
if (result.interrupted)
|
|
2348
2412
|
return false;
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
const body = result.stopped ? 'Stopped.' : result.output.trim();
|
|
2354
|
-
if (!body) {
|
|
2355
|
-
fail('it said nothing');
|
|
2413
|
+
const failureKind = result.failureKind ?? nativeFailureKind(result.saidTail ?? '');
|
|
2414
|
+
if (!result.ok && !result.stopped && failureKind === 'transient' && attempts + 1 < MAX_REPLY_ATTEMPTS) {
|
|
2415
|
+
fail(failureMessage(plan.agent, failureKind));
|
|
2416
|
+
owned?.complete();
|
|
2356
2417
|
return false;
|
|
2357
2418
|
}
|
|
2419
|
+
if (!result.ok && !result.stopped && failureKind === 'transient')
|
|
2420
|
+
fail(failureMessage(plan.agent, failureKind));
|
|
2421
|
+
const succeeded = result.ok && !!result.output.trim();
|
|
2422
|
+
const body = result.stopped ? 'Stopped.' : succeeded ? result.output.trim() : null;
|
|
2358
2423
|
if (!attempt || !machineId)
|
|
2359
2424
|
throw new Error('The reply has no exact source lease identity.');
|
|
2360
|
-
const
|
|
2361
|
-
|
|
2362
|
-
|
|
2425
|
+
const terminal = { surface: 'reply', completedAt: Date.now(), harnessIdentity, ok: succeeded, text: body, reason: succeeded ? '' : failureMessage(plan.agent, failureKind), failureKind: succeeded ? undefined : failureKind, stopped: !!result.stopped, replyingToClaim, workerIsLive: result.ok && workerIsLive };
|
|
2426
|
+
owned?.deferOutcome(terminal);
|
|
2427
|
+
// A durable terminal result is awaiting acknowledgement, never another native attempt.
|
|
2428
|
+
for (const id of sourceIds)
|
|
2429
|
+
state.replyAttempts.delete(id);
|
|
2430
|
+
const answered = await settleReplyOutcome(client, machineId, attempt, terminal, () => {
|
|
2431
|
+
if (succeeded && !result.stopped)
|
|
2432
|
+
observeHarness?.(plan.agent, 'authenticated', 'dispatch-success', terminal.completedAt, terminal.harnessIdentity);
|
|
2433
|
+
else if (failureKind === 'authentication')
|
|
2434
|
+
observeHarness?.(plan.agent, 'sign-in-required', 'provider-rejected', terminal.completedAt, terminal.harnessIdentity);
|
|
2363
2435
|
});
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
const
|
|
2367
|
-
if (outcome === 'stale')
|
|
2368
|
-
return false;
|
|
2369
|
-
if (outcome !== 'accepted' && outcome !== 'already_settled')
|
|
2370
|
-
throw new Error('The reply acknowledgement was incomplete.');
|
|
2371
|
-
for (const id of messageIds)
|
|
2436
|
+
owned?.acknowledgeOutcome();
|
|
2437
|
+
owned?.complete();
|
|
2438
|
+
for (const id of sourceIds)
|
|
2372
2439
|
state.replyAttempts.delete(id);
|
|
2373
|
-
|
|
2374
|
-
await releaseStoppedTodo(client, todoId, replyingToClaim);
|
|
2375
|
-
else if (workerIsLive && replyingToClaim) {
|
|
2376
|
-
const scheduled = await client.from('cliv2_loose_todos')
|
|
2377
|
-
.update({ claimed_machine_id: null, claimed_agent: null, claimed_at: null })
|
|
2378
|
-
.eq('id', todoId).eq('claimed_at', replyingToClaim).in('state', ['working', 'needs-input']);
|
|
2379
|
-
if (scheduled.error)
|
|
2380
|
-
throw scheduled.error;
|
|
2381
|
-
}
|
|
2382
|
-
return !result.stopped;
|
|
2440
|
+
return answered;
|
|
2383
2441
|
}
|
|
2384
2442
|
/** 18k Slice 7 — say ONCE that the take is failing, not every 10 seconds.
|
|
2385
2443
|
*
|
|
@@ -3708,10 +3766,8 @@ export async function spawnAgent(agent, prompt, timeoutMs = ORCHESTRATOR_SPAWN_T
|
|
|
3708
3766
|
const codexHome = agent === 'claude'
|
|
3709
3767
|
? null
|
|
3710
3768
|
: ensureCodexRunHome(mcpServer, runTodoId, runTodoId ? `${runTodoId}-${randomUUID().slice(0, 8)}` : null);
|
|
3711
|
-
if (
|
|
3712
|
-
|
|
3713
|
-
'credential to seed a per-run home. This worker inherits the MCP servers, instruction ' +
|
|
3714
|
-
'files and permission posture on this machine.');
|
|
3769
|
+
if (codexHome instanceof CodexHomeFailure) {
|
|
3770
|
+
return { ok: false, output: '', error: codexHome.message, failureKind: codexHome.kind };
|
|
3715
3771
|
}
|
|
3716
3772
|
/* Spread the parent's env rather than replacing it: codex needs PATH, HOME,
|
|
3717
3773
|
the proxy variables and the user's shell environment to run at all. Only the
|
|
@@ -4126,7 +4182,7 @@ export async function spawnAgent(agent, prompt, timeoutMs = ORCHESTRATOR_SPAWN_T
|
|
|
4126
4182
|
401 that means signed out. Each tailed separately so a chatty stdout
|
|
4127
4183
|
cannot push stderr out of the window. */
|
|
4128
4184
|
const saidTail = `${stdout.slice(-STDERR_TAIL_BYTES)}\n${stderrTail}`;
|
|
4129
|
-
return settle({ ok: false, output: '', error: `${agent} ${how}`, saidTail });
|
|
4185
|
+
return settle({ ok: false, output: '', error: `${agent} ${how}`, saidTail, failureKind: nativeFailureKind(stdout, stderrTail) });
|
|
4130
4186
|
}
|
|
4131
4187
|
/* 18c SLICE 8 — CODEX CAN EXIT 0 ON A TURN THAT FAILED, so the exit code
|
|
4132
4188
|
above is not the last word. Checked AFTER the code, because a non-zero
|
|
@@ -4136,7 +4192,7 @@ export async function spawnAgent(agent, prompt, timeoutMs = ORCHESTRATOR_SPAWN_T
|
|
|
4136
4192
|
release path, like every other failure, so the unit is retried. */
|
|
4137
4193
|
if (codexTurnFailed !== null) {
|
|
4138
4194
|
const saidTail = `${stdout.slice(-STDERR_TAIL_BYTES)}\n${stderrTail}`;
|
|
4139
|
-
return settle({ ok: false, output: '', error: `${agent} ${codexTurnFailed}`, saidTail });
|
|
4195
|
+
return settle({ ok: false, output: '', error: `${agent} ${codexTurnFailed}`, saidTail, failureKind: nativeFailureKind(stdout, stderrTail) });
|
|
4140
4196
|
}
|
|
4141
4197
|
/* PREFER WHAT WAS CAPTURED LIVE, FOR BOTH HARNESSES (18c Slice 8 — it used
|
|
4142
4198
|
to be claude only). `stdout` is now the fallback only for a run that
|
|
@@ -4353,7 +4409,7 @@ export async function startWorker(client, todoId, machineId, agent, warn = conso
|
|
|
4353
4409
|
/** Move a worker through its lifecycle. `died` REQUIRES a reason — the table's
|
|
4354
4410
|
* check constraint refuses the row otherwise, deliberately: a failure the panel
|
|
4355
4411
|
* cannot explain gives the user nothing to act on. */
|
|
4356
|
-
export async function updateWorker(client, workerId, state, error = null, warn = console.warn) {
|
|
4412
|
+
export async function updateWorker(client, workerId, state, error = null, warn = console.warn, strict = false) {
|
|
4357
4413
|
if (!workerId)
|
|
4358
4414
|
return;
|
|
4359
4415
|
try {
|
|
@@ -4370,6 +4426,8 @@ export async function updateWorker(client, workerId, state, error = null, warn =
|
|
|
4370
4426
|
throw writeError;
|
|
4371
4427
|
}
|
|
4372
4428
|
catch (err) {
|
|
4429
|
+
if (strict)
|
|
4430
|
+
throw err;
|
|
4373
4431
|
warn(`[orchestrator] could not update the worker: ${err.message}`);
|
|
4374
4432
|
}
|
|
4375
4433
|
}
|
|
@@ -5270,9 +5328,14 @@ export async function orchestratorTick(deps) {
|
|
|
5270
5328
|
return 'busy';
|
|
5271
5329
|
state.inFlight += 1;
|
|
5272
5330
|
try {
|
|
5273
|
-
await reconcileInterruptedWork(deps.client, deps.machineId);
|
|
5331
|
+
await reconcileInterruptedWork(deps.client, deps.machineId, deps.observeHarness);
|
|
5274
5332
|
return await runTick(deps, state, now, log, warn);
|
|
5275
5333
|
}
|
|
5334
|
+
catch (error) {
|
|
5335
|
+
// This supervising tick must survive an outage and retry its saved recovery.
|
|
5336
|
+
warn(`orchestrator poll failed, will retry: ${error instanceof Error ? error.message : JSON.stringify(error)}`);
|
|
5337
|
+
return 'failed';
|
|
5338
|
+
}
|
|
5276
5339
|
finally {
|
|
5277
5340
|
state.inFlight -= 1;
|
|
5278
5341
|
}
|
|
@@ -5295,6 +5358,7 @@ async function runTick(deps, state, now, log, warn) {
|
|
|
5295
5358
|
line is precisely what was wrong with it: a failing agent stopped the whole
|
|
5296
5359
|
daemon rather than stopping itself. The wait is now per request, on the row,
|
|
5297
5360
|
and the claim itself skips a request whose moment has not come. */
|
|
5361
|
+
const recovered = hasOwnedWorkContext() ? recoveredRpcClaims('cli').find(row => row.action === 'cliv2_claim_next_loose_todo' && agents.includes(row.args?.p_agent)) : undefined;
|
|
5298
5362
|
let role;
|
|
5299
5363
|
try {
|
|
5300
5364
|
const { data, error } = await client
|
|
@@ -5311,8 +5375,12 @@ async function runTick(deps, state, now, log, warn) {
|
|
|
5311
5375
|
// is not a designation, and guessing would be exactly the fleet-wide race
|
|
5312
5376
|
// the machine check exists to prevent.
|
|
5313
5377
|
warn(`orchestrator designation read failed, will retry: ${err.message}`);
|
|
5314
|
-
|
|
5378
|
+
if (!recovered)
|
|
5379
|
+
return 'failed';
|
|
5380
|
+
role = { kind: 'none' };
|
|
5315
5381
|
}
|
|
5382
|
+
if (recovered)
|
|
5383
|
+
role = { kind: 'this-machine', agent: recovered.args.p_agent };
|
|
5316
5384
|
reportRole(state, role, machineName, log);
|
|
5317
5385
|
if (role.kind !== 'this-machine')
|
|
5318
5386
|
return 'not-designated';
|
|
@@ -5383,7 +5451,7 @@ async function runTick(deps, state, now, log, warn) {
|
|
|
5383
5451
|
for (;;) {
|
|
5384
5452
|
let claim;
|
|
5385
5453
|
try {
|
|
5386
|
-
claim = await claimNextTodo(client, machineId, role.agent, [...skip, ...legacyHeldTodoIds()]);
|
|
5454
|
+
claim = await claimNextTodo(client, machineId, role.agent, [...skip, ...legacyHeldTodoIds()], !!recovered);
|
|
5387
5455
|
if (claim?.claimOperationId)
|
|
5388
5456
|
claimOperations.push(claim.claimOperationId);
|
|
5389
5457
|
}
|
|
@@ -5465,7 +5533,7 @@ async function runTick(deps, state, now, log, warn) {
|
|
|
5465
5533
|
}
|
|
5466
5534
|
const dispatched = dispatchClaim(deps, state, now, log, warn, role, runClaim, context, chosen);
|
|
5467
5535
|
if (hasOwnedWorkContext())
|
|
5468
|
-
finishRpcClaims(claimOperations);
|
|
5536
|
+
finishRpcClaims(claimOperations, true);
|
|
5469
5537
|
endClaim();
|
|
5470
5538
|
return await dispatched;
|
|
5471
5539
|
}
|
|
@@ -5497,7 +5565,7 @@ async function runTick(deps, state, now, log, warn) {
|
|
|
5497
5565
|
}
|
|
5498
5566
|
finally {
|
|
5499
5567
|
if (hasOwnedWorkContext())
|
|
5500
|
-
finishRpcClaims(claimOperations);
|
|
5568
|
+
finishRpcClaims(claimOperations, true);
|
|
5501
5569
|
endClaim();
|
|
5502
5570
|
}
|
|
5503
5571
|
}
|
|
@@ -5554,6 +5622,57 @@ async function dispatchClaim(deps, state, now, log, warn, role, claim, context,
|
|
|
5554
5622
|
forgetSecrets(claim.id);
|
|
5555
5623
|
}
|
|
5556
5624
|
}
|
|
5625
|
+
async function settleWorkerOutcome(client, workerId, claim, role, result, targeted, now, startedAt, log, warn, saved, persist, onAccepted) {
|
|
5626
|
+
let disposition = saved?.disposition;
|
|
5627
|
+
if (!disposition) {
|
|
5628
|
+
if (!result.ok) {
|
|
5629
|
+
const kind = result.failureKind ?? nativeFailureKind(result.saidTail ?? '');
|
|
5630
|
+
const reason = failureMessage(role.agent, kind);
|
|
5631
|
+
disposition = { worker: 'died', reason, todo: kind === 'transient' ? 'retry' : 'failed' };
|
|
5632
|
+
}
|
|
5633
|
+
else if (await todoHasOpenAsk(client, claim.id, warn, true)) {
|
|
5634
|
+
disposition = { worker: 'done', reason: '', todo: 'needs-input' };
|
|
5635
|
+
}
|
|
5636
|
+
else if (!await todoRunLeftATrace(client, claim.id, warn, targeted, true)) {
|
|
5637
|
+
disposition = { worker: 'died', reason: NOTHING_RECORDED_REASON, todo: 'retry' };
|
|
5638
|
+
}
|
|
5639
|
+
else
|
|
5640
|
+
disposition = { worker: 'done', reason: '', todo: 'done' };
|
|
5641
|
+
if (saved) {
|
|
5642
|
+
saved.disposition = disposition;
|
|
5643
|
+
persist?.(saved);
|
|
5644
|
+
}
|
|
5645
|
+
}
|
|
5646
|
+
if (disposition.todo === 'needs-input') {
|
|
5647
|
+
const accepted = await suspendTodoForAsk(client, claim.id, result.output, claim.claimedAt);
|
|
5648
|
+
if (accepted)
|
|
5649
|
+
onAccepted?.();
|
|
5650
|
+
await updateWorker(client, workerId, 'done', null, warn, true);
|
|
5651
|
+
log(`[orchestrator] ${shortId(claim.id)} is waiting on the user`);
|
|
5652
|
+
return 'answered';
|
|
5653
|
+
}
|
|
5654
|
+
if (disposition.todo === 'done') {
|
|
5655
|
+
const accepted = await answerTodo(client, claim.id, result.output, claim.claimedAt);
|
|
5656
|
+
if (accepted)
|
|
5657
|
+
onAccepted?.();
|
|
5658
|
+
await updateWorker(client, workerId, 'done', null, warn, true);
|
|
5659
|
+
log(`[orchestrator] answered ${shortId(claim.id)} in ${((now().getTime() - startedAt) / 1000).toFixed(1)}s`);
|
|
5660
|
+
return 'answered';
|
|
5661
|
+
}
|
|
5662
|
+
await updateWorker(client, workerId, 'died', disposition.reason, warn, true);
|
|
5663
|
+
const { fate, attempts } = disposition.todo === 'retry'
|
|
5664
|
+
? await releaseOrFail(client, claim.id, disposition.reason, now().getTime(), claim.claimedAt)
|
|
5665
|
+
: await failClaimedTodo(client, claim.id, disposition.reason, claim.claimedAt ?? null);
|
|
5666
|
+
if (fate === 'stale')
|
|
5667
|
+
return 'nothing-to-do';
|
|
5668
|
+
onAccepted?.();
|
|
5669
|
+
if (fate === 'failed') {
|
|
5670
|
+
warn(`[orchestrator] ${shortId(claim.id)} failed: ${disposition.reason}`);
|
|
5671
|
+
return 'failed';
|
|
5672
|
+
}
|
|
5673
|
+
warn(`[orchestrator] retrying ${shortId(claim.id)} in ${Math.round(retryDelayMs(attempts) / 1000)}s`);
|
|
5674
|
+
return 'released';
|
|
5675
|
+
}
|
|
5557
5676
|
async function dispatchClaimInner(deps, state, now, log, warn, role, claim,
|
|
5558
5677
|
/* !Cleanup Phase 7 Slice 3 — RESOLVED BY THE CALLER, and passed in rather than
|
|
5559
5678
|
resolved again here. `runTick` needs the same answer first, to know which
|
|
@@ -5669,6 +5788,7 @@ chosenCodebases = [], owned) {
|
|
|
5669
5788
|
let workerId = null;
|
|
5670
5789
|
/* !Cleanup Phase 6b (I43) — outside the try for the same reason: the `finally`
|
|
5671
5790
|
has to remove it from `livePids` however this run ended. */
|
|
5791
|
+
let terminalDeferred = false;
|
|
5672
5792
|
let spawnedPid = null;
|
|
5673
5793
|
try {
|
|
5674
5794
|
log(`[orchestrator] picked up "${claim.instruction}" (${shortId(claim.id)})`);
|
|
@@ -5855,115 +5975,35 @@ chosenCodebases = [], owned) {
|
|
|
5855
5975
|
if (result.interrupted)
|
|
5856
5976
|
return 'nothing-to-do';
|
|
5857
5977
|
if (result.stopped) {
|
|
5858
|
-
|
|
5978
|
+
terminalDeferred = !!owned;
|
|
5979
|
+
owned?.deferOutcome({ surface: 'worker', ok: false, text: null, reason: 'stopped by the user', stopped: true });
|
|
5980
|
+
await updateWorker(client, workerId, 'died', 'stopped by the user', warn, true);
|
|
5859
5981
|
await releaseStoppedTodo(client, claim.id, claim.claimedAt);
|
|
5982
|
+
owned?.acknowledgeOutcome();
|
|
5983
|
+
terminalDeferred = false;
|
|
5860
5984
|
log(`[orchestrator] ${shortId(claim.id)} was stopped by the user`);
|
|
5861
5985
|
return 'released';
|
|
5862
5986
|
}
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
re-claimed forever while reading as Working. */
|
|
5878
|
-
const { fate, attempts } = await releaseOrFail(client, claim.id, plain, now().getTime(), claim.claimedAt);
|
|
5879
|
-
if (fate === 'stale')
|
|
5880
|
-
return 'nothing-to-do';
|
|
5881
|
-
if (fate === 'failed') {
|
|
5882
|
-
warn(`[orchestrator] ${result.error} — ${shortId(claim.id)} failed after ${MAX_ATTEMPTS} attempts`);
|
|
5883
|
-
return 'failed';
|
|
5884
|
-
}
|
|
5885
|
-
/* 18a SLICE 6: THIS REQUEST WAITS, AND NOTHING ELSE DOES. The wait went
|
|
5886
|
-
onto the row with the attempt count; what is left here is saying so, in
|
|
5887
|
-
the line that used to say only "releasing". */
|
|
5888
|
-
const seconds = Math.round(retryDelayMs(attempts) / 1000);
|
|
5889
|
-
warn(`[orchestrator] ${result.error}: retrying ${shortId(claim.id)} in ${seconds}s`);
|
|
5890
|
-
return 'released';
|
|
5891
|
-
}
|
|
5892
|
-
/* !Cleanup PHASE 6 (I15) — A QUESTION IS NOT A FINISHED JOB.
|
|
5893
|
-
The child exited cleanly, which until now meant `done` unconditionally.
|
|
5894
|
-
But a one-shot headless run ASKS AND EXITS — that is the only way it can
|
|
5895
|
-
handle a question — so the clean exit says nothing about whether the work
|
|
5896
|
-
is over. If an ask is open on this unit, the truthful state is that it
|
|
5897
|
-
needs the user, and the Inbox already has the question. */
|
|
5898
|
-
if (await todoHasOpenAsk(client, claim.id, warn)) {
|
|
5899
|
-
await suspendTodoForAsk(client, claim.id, result.output, claim.claimedAt);
|
|
5900
|
-
await updateWorker(client, workerId, 'done', null, warn);
|
|
5901
|
-
log(`[orchestrator] ${shortId(claim.id)} is waiting on the user`);
|
|
5902
|
-
return 'answered';
|
|
5903
|
-
}
|
|
5904
|
-
/* A CONFIDENT ANSWER WITH NOTHING BEHIND IT IS NOT A FINISHED JOB.
|
|
5905
|
-
See `todoRunLeftATrace` for the measurement: a codex worker that was
|
|
5906
|
-
correctly configured, connected, and fetched the tool list still reported
|
|
5907
|
-
that it had no tools, twice in three identical runs, and one reported
|
|
5908
|
-
ATTACHING a screenshot it never attached. Every one of them exited 0 with
|
|
5909
|
-
prose on stdout, which is all the two checks above look at, so every one
|
|
5910
|
-
of them landed here and was written `done`.
|
|
5911
|
-
|
|
5912
|
-
PLACED AFTER THE ASK CHECK, and that order is load-bearing. A run that
|
|
5913
|
-
asked a question and exited has legitimately recorded something and is
|
|
5914
|
-
already handled as `needs-input`; reaching this check first would relabel
|
|
5915
|
-
the product's most important state as a failure.
|
|
5916
|
-
|
|
5917
|
-
RELEASED FOR RETRY RATHER THAN FAILED OUTRIGHT, through the SAME
|
|
5918
|
-
`releaseOrFail` the dead-worker path uses. This is intermittent by nature
|
|
5919
|
-
(the third run of the identical prompt worked perfectly), so a retry is
|
|
5920
|
-
very likely to succeed, and the attempt bound is what stops it looping
|
|
5921
|
-
forever. At the bound the card says why, in words about what is KNOWN
|
|
5922
|
-
(nothing was recorded) rather than repeating the agent's own account of a
|
|
5923
|
-
run that has just been shown to be an unreliable narrator.
|
|
5924
|
-
|
|
5925
|
-
`targeted` IS PASSED, and without it this check is blind to the primary
|
|
5926
|
-
flow. A session opened on a work item has `todo_id` NULL by the table's
|
|
5927
|
-
own one-anchor constraint, and the prompt tells the agent to open it that
|
|
5928
|
-
way whenever there is an item, so a `todo_id`-only lookup could never
|
|
5929
|
-
match a targeted run. See `todoRunLeftATrace`. It is the same value the
|
|
5930
|
-
scope gate above already resolved, so the two cannot disagree about which
|
|
5931
|
-
item this request is for. */
|
|
5932
|
-
if (!await todoRunLeftATrace(client, claim.id, warn, targeted)) {
|
|
5933
|
-
/* THE WORKER DIED WITH NO STEP, and that blank is load-bearing rather
|
|
5934
|
-
than incidental. `interruptedRunStep` looks up the most recent `died`
|
|
5935
|
-
worker on this request and `describeInterruptedRun` then tells the
|
|
5936
|
-
REPLACEMENT agent that a previous attempt "stopped partway" and that
|
|
5937
|
-
anything it did "IS STILL THERE". For a genuinely toolless run that is
|
|
5938
|
-
true and useful. Clearing the step keeps the claim honest about the one
|
|
5939
|
-
thing that is known: this run recorded nothing, so there is no last
|
|
5940
|
-
action to hand on and nothing for the next agent to resume from. */
|
|
5941
|
-
await updateWorker(client, workerId, 'died', NOTHING_RECORDED_REASON, warn);
|
|
5942
|
-
const { fate, attempts } = await releaseOrFail(client, claim.id, NOTHING_RECORDED_REASON, now().getTime(), claim.claimedAt);
|
|
5943
|
-
if (fate === 'stale')
|
|
5944
|
-
return 'nothing-to-do';
|
|
5945
|
-
if (fate === 'failed') {
|
|
5946
|
-
warn(`[orchestrator] ${shortId(claim.id)} recorded nothing and failed after ${MAX_ATTEMPTS} attempts`);
|
|
5947
|
-
return 'failed';
|
|
5948
|
-
}
|
|
5949
|
-
const seconds = Math.round(retryDelayMs(attempts) / 1000);
|
|
5950
|
-
warn(`[orchestrator] ${shortId(claim.id)} recorded nothing: retrying in ${seconds}s`);
|
|
5951
|
-
return 'released';
|
|
5952
|
-
}
|
|
5953
|
-
/* FENCED ON THE CLAIM THIS DISPATCH TOOK (18d Slice 4). If this run handed
|
|
5954
|
-
its stage on, the claim is already cleared and a fresh worker is on the
|
|
5955
|
-
row, so this write must do nothing rather than mark the request finished
|
|
5956
|
-
over work that is still running. See answerTodo's own comment. */
|
|
5957
|
-
await answerTodo(client, claim.id, result.output, claim.claimedAt);
|
|
5958
|
-
// The worker finished and its answer is committed. Marked done AFTER the
|
|
5959
|
-
// answer write, so a worker is never reported finished for a unit that has
|
|
5960
|
-
// no answer on it.
|
|
5961
|
-
await updateWorker(client, workerId, 'done', null, warn);
|
|
5962
|
-
const seconds = ((now().getTime() - startedAt) / 1000).toFixed(1);
|
|
5963
|
-
log(`[orchestrator] answered ${shortId(claim.id)} in ${seconds}s`);
|
|
5964
|
-
return 'answered';
|
|
5987
|
+
terminalDeferred = !!owned;
|
|
5988
|
+
const terminal = { surface: 'worker', ok: result.ok, text: result.ok ? result.output : null,
|
|
5989
|
+
reason: result.ok ? '' : failureMessage(role.agent, result.failureKind ?? nativeFailureKind(result.saidTail ?? '')),
|
|
5990
|
+
failureKind: result.failureKind ?? nativeFailureKind(result.saidTail ?? ''), workItemId: targeted };
|
|
5991
|
+
owned?.deferOutcome(terminal);
|
|
5992
|
+
const settled = await settleWorkerOutcome(client, workerId, claim, role, result, targeted, now, startedAt, log, warn, terminal, owned?.deferOutcome, () => {
|
|
5993
|
+
if (result.ok)
|
|
5994
|
+
deps.observeHarness?.(role.agent, 'authenticated', 'dispatch-success');
|
|
5995
|
+
else if (result.failureKind === 'authentication')
|
|
5996
|
+
deps.observeHarness?.(role.agent, 'sign-in-required', 'provider-rejected');
|
|
5997
|
+
});
|
|
5998
|
+
owned?.acknowledgeOutcome();
|
|
5999
|
+
terminalDeferred = false;
|
|
6000
|
+
return settled;
|
|
5965
6001
|
}
|
|
5966
6002
|
catch (err) {
|
|
6003
|
+
if (terminalDeferred) {
|
|
6004
|
+
warn('The completed execution is waiting for its original account to acknowledge the result.');
|
|
6005
|
+
return 'failed';
|
|
6006
|
+
}
|
|
5967
6007
|
// The answer write itself failed (network, RLS, a reverted migration). The
|
|
5968
6008
|
// todo is CLAIMED and has no answer — the worst state — so release it here
|
|
5969
6009
|
// too. A release that also fails is warned and the row is left claimed;
|
|
@@ -6036,10 +6076,59 @@ function cliClaimReferences(action, result) {
|
|
|
6036
6076
|
throw new Error('The claim journal returned an unrecognized lease identity.');
|
|
6037
6077
|
});
|
|
6038
6078
|
}
|
|
6039
|
-
async function
|
|
6079
|
+
async function recoveredCliClaim(client, action, args) {
|
|
6080
|
+
const skip = (args.p_skip_ids ?? args.p_skip_todo_ids ?? []);
|
|
6081
|
+
for (const claim of recoveredRpcClaims('cli').filter(row => row.action === action)) {
|
|
6082
|
+
if (action === 'cliv2_claim_next_loose_todo' && claim.args?.p_agent !== args.p_agent)
|
|
6083
|
+
continue;
|
|
6084
|
+
const rows = [];
|
|
6085
|
+
for (const row of claim.result ?? []) {
|
|
6086
|
+
const ref = cliClaimReferences(action, [row])[0];
|
|
6087
|
+
if (ref.surface === 'panel' || !pendingRpcReference(ref))
|
|
6088
|
+
continue;
|
|
6089
|
+
if (skip.includes(ref.todoId) || Array.isArray(args.p_todo_ids) && !args.p_todo_ids.includes(ref.todoId))
|
|
6090
|
+
continue;
|
|
6091
|
+
let current = false;
|
|
6092
|
+
if (ref.surface === 'worker') {
|
|
6093
|
+
const read = await client.from('cliv2_loose_todos').select('state,claimed_at,claimed_machine_id').eq('id', ref.todoId).maybeSingle();
|
|
6094
|
+
if (read.error)
|
|
6095
|
+
throw read.error;
|
|
6096
|
+
current = !!read.data && read.data.claimed_at === ref.claimedAt && read.data.claimed_machine_id === args.p_machine_id && ['working', 'needs-input'].includes(read.data.state);
|
|
6097
|
+
}
|
|
6098
|
+
else if (action === 'cliv2_take_run_messages') {
|
|
6099
|
+
const source = ref.messageLeases[0];
|
|
6100
|
+
const read = await client.from('cliv2_run_messages').select('taken_at,taken_machine_id,answered_by,interrupted_at').eq('id', source.id).maybeSingle();
|
|
6101
|
+
if (read.error)
|
|
6102
|
+
throw read.error;
|
|
6103
|
+
current = !!read.data && read.data.taken_at === source.taken_at && read.data.taken_machine_id === args.p_machine_id && !read.data.answered_by && !read.data.interrupted_at;
|
|
6104
|
+
}
|
|
6105
|
+
else {
|
|
6106
|
+
const source = ref.grantLeases[0];
|
|
6107
|
+
const read = await client.from('cliv2_permission_requests').select('resume_taken_at,resume_machine_id,resume_answered_by,resume_interrupted_at,state').eq('id', source.id).maybeSingle();
|
|
6108
|
+
if (read.error)
|
|
6109
|
+
throw read.error;
|
|
6110
|
+
current = !!read.data && read.data.resume_taken_at === source.resume_taken_at && read.data.resume_machine_id === args.p_machine_id && !read.data.resume_answered_by && !read.data.resume_interrupted_at && read.data.state === 'granted';
|
|
6111
|
+
}
|
|
6112
|
+
if (current)
|
|
6113
|
+
rows.push(row);
|
|
6114
|
+
else
|
|
6115
|
+
retireRpcReference(ref);
|
|
6116
|
+
}
|
|
6117
|
+
if (rows.length)
|
|
6118
|
+
return { data: rows, error: null, operationId: claim.id };
|
|
6119
|
+
finishRpcClaims([claim.id], true);
|
|
6120
|
+
}
|
|
6121
|
+
return null;
|
|
6122
|
+
}
|
|
6123
|
+
async function cliClaim(client, action, args, recoveryOnly = false) {
|
|
6040
6124
|
if (!hasOwnedWorkContext())
|
|
6041
6125
|
return { ...await client.rpc(action, args), operationId: undefined };
|
|
6042
|
-
const
|
|
6126
|
+
const recovered = await recoveredCliClaim(client, action, args);
|
|
6127
|
+
if (recovered)
|
|
6128
|
+
return recovered;
|
|
6129
|
+
if (recoveryOnly)
|
|
6130
|
+
return { data: [], error: null, operationId: undefined };
|
|
6131
|
+
const operationId = beginRpcClaim('cli', action, args);
|
|
6043
6132
|
try {
|
|
6044
6133
|
const response = await client.rpc(action, { ...args, p_operation_id: operationId });
|
|
6045
6134
|
if (!response.error) {
|
|
@@ -6049,7 +6138,10 @@ async function cliClaim(client, action, args) {
|
|
|
6049
6138
|
const row = journal.data?.[0];
|
|
6050
6139
|
if (row?.outcome !== 'completed' || row.action !== action)
|
|
6051
6140
|
throw new Error('The claim was cancelled before it could start.');
|
|
6052
|
-
recordRpcClaims(operationId, cliClaimReferences(action, row.result));
|
|
6141
|
+
recordRpcClaims(operationId, cliClaimReferences(action, row.result), false, row.result);
|
|
6142
|
+
if (row.result.length === 0)
|
|
6143
|
+
finishRpcClaims([operationId]);
|
|
6144
|
+
return { ...response, data: row.result, operationId: row.result.length ? operationId : undefined };
|
|
6053
6145
|
}
|
|
6054
6146
|
if (response.error)
|
|
6055
6147
|
deferRpcClaim(operationId);
|
|
@@ -6083,13 +6175,46 @@ async function legacyRows(client, table, columns, todoId) {
|
|
|
6083
6175
|
throw new Error('The old work snapshot has no continuation identity.');
|
|
6084
6176
|
}
|
|
6085
6177
|
}
|
|
6086
|
-
export async function reconcileInterruptedWork(client, machineId) {
|
|
6178
|
+
export async function reconcileInterruptedWork(client, machineId, observeHarness) {
|
|
6087
6179
|
if (!hasOwnedWorkContext())
|
|
6088
6180
|
return;
|
|
6089
6181
|
const capability = await client.rpc('cliv2_interrupt_machine_work', { p_machine_id: machineId,
|
|
6090
6182
|
p_operation_id: randomUUID(), p_interrupted_at: new Date().toISOString(), p_workers: [], p_replies: [] });
|
|
6091
6183
|
if (capability.error)
|
|
6092
6184
|
throw capability.error;
|
|
6185
|
+
for (const pending of pendingTerminalOutcomes()) {
|
|
6186
|
+
if (pending.reference.surface === 'reply') {
|
|
6187
|
+
await settleReplyOutcome(client, machineId, pending.reference, pending.outcome, () => {
|
|
6188
|
+
const outcome = pending.outcome;
|
|
6189
|
+
if (!outcome.completedAt || !outcome.harnessIdentity || !['claude', 'codex'].includes(pending.harness ?? '') || outcome.stopped)
|
|
6190
|
+
return;
|
|
6191
|
+
if (outcome.ok || outcome.failureKind === 'authentication')
|
|
6192
|
+
observeHarness?.(pending.harness, outcome.ok ? 'authenticated' : 'sign-in-required', outcome.ok ? 'dispatch-success' : 'provider-rejected', outcome.completedAt, outcome.harnessIdentity);
|
|
6193
|
+
});
|
|
6194
|
+
acknowledgeTerminalOutcome(pending.id);
|
|
6195
|
+
continue;
|
|
6196
|
+
}
|
|
6197
|
+
if (pending.reference.surface !== 'worker')
|
|
6198
|
+
continue;
|
|
6199
|
+
const ref = pending.reference;
|
|
6200
|
+
if (pending.outcome.stopped) {
|
|
6201
|
+
await updateWorker(client, ref.workerId, 'died', 'stopped by the user', message => console.warn(message), true);
|
|
6202
|
+
await releaseStoppedTodo(client, ref.todoId, ref.claimedAt);
|
|
6203
|
+
acknowledgeTerminalOutcome(pending.id);
|
|
6204
|
+
continue;
|
|
6205
|
+
}
|
|
6206
|
+
const { data, error } = await client.from('cliv2_loose_todos').select('claimed_at,state').eq('id', ref.todoId).maybeSingle();
|
|
6207
|
+
if (error)
|
|
6208
|
+
throw error;
|
|
6209
|
+
if (data?.claimed_at === ref.claimedAt && ['working', 'needs-input'].includes(data.state)) {
|
|
6210
|
+
await settleWorkerOutcome(client, ref.workerId, { id: ref.todoId, claimedAt: ref.claimedAt }, { agent: pending.harness ?? 'Agent' }, { ok: pending.outcome.ok, output: pending.outcome.text ?? '', error: pending.outcome.reason, failureKind: pending.outcome.failureKind }, pending.outcome.workItemId ?? null, () => new Date(), Date.now(), () => { }, message => console.warn(message), pending.outcome, value => updateTerminalOutcome(pending.id, value));
|
|
6211
|
+
}
|
|
6212
|
+
else {
|
|
6213
|
+
const disposition = pending.outcome.disposition;
|
|
6214
|
+
await updateWorker(client, ref.workerId, disposition?.worker ?? (pending.outcome.ok ? 'done' : 'died'), disposition?.reason || pending.outcome.reason || null, message => console.warn(message), true);
|
|
6215
|
+
}
|
|
6216
|
+
acknowledgeTerminalOutcome(pending.id);
|
|
6217
|
+
}
|
|
6093
6218
|
for (const claim of pendingRpcClaims('cli')) {
|
|
6094
6219
|
const journal = await client.rpc('cliv2_reconcile_claim', { p_machine_id: machineId, p_operation_id: claim.id });
|
|
6095
6220
|
if (journal.error)
|
|
@@ -6098,7 +6223,7 @@ export async function reconcileInterruptedWork(client, machineId) {
|
|
|
6098
6223
|
if (row?.outcome === 'cancelled')
|
|
6099
6224
|
recordRpcClaims(claim.id, [], true);
|
|
6100
6225
|
else if (row?.outcome === 'completed' && row.action === claim.action)
|
|
6101
|
-
recordRpcClaims(claim.id, cliClaimReferences(claim.action, row.result),
|
|
6226
|
+
recordRpcClaims(claim.id, cliClaimReferences(claim.action, row.result), false, row.result);
|
|
6102
6227
|
else
|
|
6103
6228
|
throw new Error('The pending claim could not be reconciled.');
|
|
6104
6229
|
}
|