@ctrl-spc/cs 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/codex-home.js +72 -7
- package/dist/config.js +17 -0
- package/dist/index.js +93 -12
- package/dist/mcp.js +15 -6
- package/dist/panel3/answer.js +12 -12
- package/dist/panel3/checkout.js +522 -2
- package/dist/panel3/cli.js +49 -62
- package/dist/panel3/client.js +53 -16
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/prompt.js +473 -62
- package/dist/panel3/run.js +1206 -111
- package/dist/panel3/say.js +10 -10
- package/dist/panel3/session.js +128 -0
- package/dist/panel3/show.js +197 -23
- package/dist/panel3/spawn.js +85 -20
- package/dist/panel3/tools.js +677 -51
- package/dist/presence.js +8 -0
- package/dist/skills.js +165 -0
- package/dist/workflows.js +68 -0
- package/package.json +2 -3
package/dist/panel3/run.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `startPanel`'s own comment at the foot of this file carries the reasoning.
|
|
9
9
|
*
|
|
10
10
|
* ---------------------------------------------------------------------------
|
|
11
|
-
* WHAT THIS IS. `
|
|
11
|
+
* WHAT THIS IS. `cs say` puts a card and a user turn on the record and returns,
|
|
12
12
|
* because ux.md requires the acknowledgement never to wait for a spawn. This is
|
|
13
13
|
* the other half: the loop that notices the turn, leases it, writes a run row,
|
|
14
14
|
* starts a real agent, and puts what that agent said back on the card.
|
|
@@ -132,18 +132,21 @@
|
|
|
132
132
|
// and takes the leading comment with it, so a file whose first statement is
|
|
133
133
|
// `import type` loses its v3 header in the published `dist/`.
|
|
134
134
|
import { out, returned, signedInClient } from './client.js';
|
|
135
|
-
import { answerPrompt, escalationPrompt, levelOnePrompt, readBackPrompt, resumePrompt, retryPrompt, } from './prompt.js';
|
|
136
|
-
import { attachmentLine, loadAttachments, loadOutputNames, outputOf } from './show.js';
|
|
135
|
+
import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, } from './prompt.js';
|
|
136
|
+
import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
|
|
137
137
|
import { forgetSecrets, redactSecrets } from './secrets.js';
|
|
138
138
|
import { sayListening, stopListening } from './presence.js';
|
|
139
|
-
import {
|
|
139
|
+
import { designatedCoordinator } from './coordinator.js';
|
|
140
|
+
import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, worktreesOnThisMachine, } from './checkout.js';
|
|
140
141
|
import { harness, startAgent } from './spawn.js';
|
|
142
|
+
import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
|
|
141
143
|
import { startToolsServer } from './tools.js';
|
|
144
|
+
import { listPanel3CodexOwnerHomeIds, removePanel3CodexOwnerHome, } from '../codex-home.js';
|
|
142
145
|
import { getMachineIdentity, scratchDir } from '../config.js';
|
|
143
146
|
import { listCodebases } from '../codebases.js';
|
|
144
|
-
import { processIsAlive } from '../win-shell.js';
|
|
147
|
+
import { killTree, processIsAlive } from '../win-shell.js';
|
|
145
148
|
import { hostname, uptime } from 'node:os';
|
|
146
|
-
const USAGE = 'usage:
|
|
149
|
+
const USAGE = 'usage: run [--once]';
|
|
147
150
|
/** How long between takes. Short, because it is the whole delay between a user
|
|
148
151
|
* sending and a card showing an agent on it, and the take is one small indexed
|
|
149
152
|
* read against the user's own rows. */
|
|
@@ -200,6 +203,84 @@ export const MAX_ATTEMPTS = 3;
|
|
|
200
203
|
* because an agent asked for it, and its level comes off the row
|
|
201
204
|
* `panel3_dispatch` wrote rather than from anything here. */
|
|
202
205
|
const LEVEL = 1;
|
|
206
|
+
const artifactAnswer = (id, revision, selected) => {
|
|
207
|
+
if (!id || revision === null || revision === undefined || selected?.length !== 1)
|
|
208
|
+
return null;
|
|
209
|
+
const answer = selected[0]?.trim().toLowerCase();
|
|
210
|
+
return answer === 'approve' || answer === 'request changes'
|
|
211
|
+
? { id, revision, answer }
|
|
212
|
+
: null;
|
|
213
|
+
};
|
|
214
|
+
export function deliveredArtifactAnswer(events, delivered) {
|
|
215
|
+
if (!delivered.mine || delivered.ask_id === null)
|
|
216
|
+
return null;
|
|
217
|
+
const event = events.find((candidate) => candidate.kind === 'question' && candidate.id === delivered.ask_id);
|
|
218
|
+
const answer = artifactAnswer(event?.relatedArtifactId, event?.relatedArtifactRevision, event?.artifactAnswer ? [event.artifactAnswer] : null);
|
|
219
|
+
return answer === null ? null : { questionId: delivered.ask_id, ...answer };
|
|
220
|
+
}
|
|
221
|
+
async function rearmedArtifactAnswer(client, askId) {
|
|
222
|
+
const asks = await withAskContent(client, await returned(client.from('panel3_asks').select(`id, ${ASK_CONTENT_COLUMNS}`).eq('id', askId), 'read', `question ${askId}`));
|
|
223
|
+
const ask = asks[0];
|
|
224
|
+
return ask === undefined
|
|
225
|
+
? null
|
|
226
|
+
: artifactAnswer(ask.related_artifact_id, ask.related_artifact_revision, ask.selected_options);
|
|
227
|
+
}
|
|
228
|
+
const LAND = 'Put this work on the main branch';
|
|
229
|
+
const LEAVE = 'Leave it on its branch';
|
|
230
|
+
export async function landingOffer(client, askId) {
|
|
231
|
+
const asks = await withAskContent(client, await returned(client.from('panel3_asks').select(`id, offers_landing, ${ASK_CONTENT_COLUMNS}`).eq('id', askId), 'read', `question ${askId}`));
|
|
232
|
+
const ask = asks[0];
|
|
233
|
+
if (ask === undefined || ask.offers_landing !== true)
|
|
234
|
+
return null;
|
|
235
|
+
const selected = ask.selected_options ?? [];
|
|
236
|
+
if (selected.length !== 1)
|
|
237
|
+
return 'unclear';
|
|
238
|
+
return selected[0] === LAND ? 'land' : selected[0] === LEAVE ? 'leave' : 'unclear';
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* ═══ THE PRODUCT PERFORMS THE LANDING, HERE, BEFORE THE AGENT THAT WILL SPEAK
|
|
242
|
+
* ABOUT IT STARTS. ═══
|
|
243
|
+
*
|
|
244
|
+
* Not a tool, because a tool means an agent decides whether a yes takes effect,
|
|
245
|
+
* and an agent that finishes the card instead leaves a person who clicked yes
|
|
246
|
+
* with nothing. Not the poll's sweep, because the sweep cannot tell the agent
|
|
247
|
+
* what it did.
|
|
248
|
+
*
|
|
249
|
+
* ═══ AND A FAILURE DOES NOT END THE RUN. ═══ The agent has to be started to
|
|
250
|
+
* tell the person, so what happened is returned as the sentence it will be
|
|
251
|
+
* handed rather than written to `failed_because`.
|
|
252
|
+
*/
|
|
253
|
+
export function landCardWork(where, outcome) {
|
|
254
|
+
const card = where.card;
|
|
255
|
+
if (outcome !== 'land')
|
|
256
|
+
return { outcome, branch: card?.branch ?? null, base: card?.base ?? null };
|
|
257
|
+
if (card === null) {
|
|
258
|
+
return { outcome: 'refused', because: 'This card has no working copy on this machine, so there is nothing to put back.' };
|
|
259
|
+
}
|
|
260
|
+
if (card.landing !== 'main') {
|
|
261
|
+
return {
|
|
262
|
+
outcome: 'refused',
|
|
263
|
+
because: `Finished work in ${card.codebaseName} does not go onto ${card.base}, so nothing `
|
|
264
|
+
+ 'was merged. The work is on its branch.',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
/* THE COMMIT FIRST, AND ITS ABSENCE IS NOT A FAILURE. Between the answer and
|
|
269
|
+
this re-arm every run on the card has ended, so the sweep may already have
|
|
270
|
+
committed the copy and taken it away; `workingDirectory` has just rebuilt
|
|
271
|
+
it from the branch, and the only difference is that there is nothing left
|
|
272
|
+
to commit. */
|
|
273
|
+
commitCardWork(card.folder, `${card.codebaseName}'s copy of this card on branch ${card.branch}`);
|
|
274
|
+
mergeIntoBase(card.source, card.branch, card.base, card.codebaseName);
|
|
275
|
+
return { outcome: 'landed', branch: card.branch, base: card.base };
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
return {
|
|
279
|
+
outcome: 'refused',
|
|
280
|
+
because: error instanceof Error ? error.message : String(error),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
203
284
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
204
285
|
/**
|
|
205
286
|
* ═══ NOTHING IN THIS FILE STAMPS A COLUMN FROM THIS MACHINE'S CLOCK ANY MORE,
|
|
@@ -268,7 +349,7 @@ function byCard(taken) {
|
|
|
268
349
|
* `levelOnePrompt` for the measured failure this closes.
|
|
269
350
|
*
|
|
270
351
|
* THE NAME IS RESOLVED, NOT READ OFF THE RECEIPT, through the one function
|
|
271
|
-
* `
|
|
352
|
+
* `cs show` uses for it. `label` is written when the thing is made and the
|
|
272
353
|
* coordinator may rename the thing on the next turn; printing the label then
|
|
273
354
|
* hands the agent the name of a different, real object. One owner for that,
|
|
274
355
|
* because two would eventually disagree about which name a card is showing.
|
|
@@ -320,6 +401,75 @@ export const briefFor = (title, turns, produced, attachments, codebases = null)
|
|
|
320
401
|
identity: codebase.gitRemoteUrl,
|
|
321
402
|
located: hasCheckoutForCodebase(codebase),
|
|
322
403
|
})) ?? null);
|
|
404
|
+
/**
|
|
405
|
+
* ═══ THE MANDATE IN FRONT OF THE PROMPT, AT EVERY ONE OF THE FIVE STARTS. ═══
|
|
406
|
+
*
|
|
407
|
+
* 19/project-context-7b, contract point 1: everything written under Agent
|
|
408
|
+
* instructions reaches every panel agent, at levels 1, 2 and 3, before it acts.
|
|
409
|
+
* Five call sites start a process (`answerCard`, `startChild`, `resumeRun`,
|
|
410
|
+
* `startRearmed`, `activateOwner`), and this is the one place the block and a
|
|
411
|
+
* prompt are put together, so the wording and the ordering cannot drift between
|
|
412
|
+
* them. The READ is `standingRulesFor` and the WORDS are `standingRules`; this
|
|
413
|
+
* is only the join.
|
|
414
|
+
*
|
|
415
|
+
* ═══ AND IT IS APPLIED TO WHAT `startAgent` IS HANDED, NEVER TO WHAT
|
|
416
|
+
* `recordProcess` WRITES. ═══ Contract point 2 lives or dies on that
|
|
417
|
+
* distinction. `panel3_runs.brief` is the immutable brief and every respawn
|
|
418
|
+
* replays it, so a block folded into the stored brief would be a snapshot of the
|
|
419
|
+
* rules as they stood at dispatch, replayed on top of a fresh copy at every
|
|
420
|
+
* later start — two sets of rules in one prompt, the stale one first. Keeping it
|
|
421
|
+
* out means the stored brief is byte-identical with and without this feature,
|
|
422
|
+
* which is asserted rather than assumed.
|
|
423
|
+
*
|
|
424
|
+
* ═══ NO WRAPPER AROUND THE FIVE STARTS. ═══ `startAgent` is synchronous and the
|
|
425
|
+
* five sites have five different endings (see `run.ts`'s own note at the level 1
|
|
426
|
+
* start: `endRun` with a level fork at two of them, `giveUp` at two more). The
|
|
427
|
+
* READ therefore sits inside each site's existing `try`, taking that site's
|
|
428
|
+
* ending, so a failure to read the mandate ends the run the way that path
|
|
429
|
+
* already ends runs and never starts a process without it (contract point 3).
|
|
430
|
+
* What is shared is the loader and the builder — which is where drift would
|
|
431
|
+
* actually happen.
|
|
432
|
+
*/
|
|
433
|
+
// Exported for the same reason `briefFor` is: the seam this feature crosses is
|
|
434
|
+
// the string handed to `startAgent`, and a test over `toolHandler('dispatch')`
|
|
435
|
+
// would prove nothing about it — `caller.dispatch` is injected, so it stops
|
|
436
|
+
// upstream of this composer.
|
|
437
|
+
export const withStandingRules = (documents, prompt,
|
|
438
|
+
/* ═══ OPTIONAL, SO THE FOUR SITES WITH A CODEBASE GAIN IT AND NOTHING ELSE
|
|
439
|
+
MOVES. ═══ worktrees-8's three sentences about the card's own copy join
|
|
440
|
+
here rather than in the brief, for the same reason the rules do: the brief
|
|
441
|
+
is immutable and replayed, and the branch is not known when it is composed.
|
|
442
|
+
Under the rules, because a standing rule the people wrote outranks a fact
|
|
443
|
+
about the machine. */
|
|
444
|
+
whereYouAre = [],
|
|
445
|
+
/* ═══ WHAT THE PERSON HAS ATTACHED, RIGHT NOW, FOR THE OWNER THAT LIVES FOR
|
|
446
|
+
THE WHOLE CARD. ═══ attaching-after-the-fact-10. Its own parameter rather
|
|
447
|
+
than folded into `whereYouAre`, which is worktrees-8's sentences about the
|
|
448
|
+
card's copy of the codebase: what is attached is not a fact about the
|
|
449
|
+
machine, and `where.block` is `[]` for a level 2 owner on a record-only
|
|
450
|
+
card, so folding in would produce a leading blank with no separator.
|
|
451
|
+
|
|
452
|
+
═══ ABOVE THE RULES, BECAUSE THE RULES SAY IT OUTRANKS THEM. ═══
|
|
453
|
+
`standingRules`' own precedence line ranks what the person attached above
|
|
454
|
+
the rules; rendering it underneath would put it below a block saying it
|
|
455
|
+
wins. Already carries its own header and its own leading blank, which this
|
|
456
|
+
join absorbs the same way it absorbs `whereYouAre`'s. */
|
|
457
|
+
whatIsAttached = []) => {
|
|
458
|
+
/* `whatWasAttached` begins its array with a blank, since its two other
|
|
459
|
+
callers join it onto lines already above it. Here it is the first thing in
|
|
460
|
+
the prompt, and a prompt that opens on an empty line is a prompt with a
|
|
461
|
+
stray blank at the top, so the leading blanks are dropped and the separator
|
|
462
|
+
below is this join's own. */
|
|
463
|
+
const attached = [...whatIsAttached];
|
|
464
|
+
while (attached[0] === '')
|
|
465
|
+
attached.shift();
|
|
466
|
+
const block = [
|
|
467
|
+
...(attached.length === 0 ? [] : [...attached, '']),
|
|
468
|
+
...standingRules(documents),
|
|
469
|
+
...(whereYouAre.length === 0 ? [] : ['', ...whereYouAre]),
|
|
470
|
+
];
|
|
471
|
+
return block.length === 0 ? prompt : [...block, '', prompt].join('\n');
|
|
472
|
+
};
|
|
323
473
|
// ---------------------------------------------------------------------------
|
|
324
474
|
// WRITES. Every one goes through the shared guard in `client.ts`, which returns
|
|
325
475
|
// the rows or throws. `.select()` on each is what makes that possible: a
|
|
@@ -344,12 +494,16 @@ export const briefFor = (title, turns, produced, attachments, codebases = null)
|
|
|
344
494
|
* the run has no pid, so once this daemon is gone recovery treats it as dead —
|
|
345
495
|
* which errs towards answering again rather than stranding.
|
|
346
496
|
*/
|
|
347
|
-
async function recordProcess(client, runId, pid, brief) {
|
|
348
|
-
|
|
497
|
+
async function recordProcess(client, runId, pid, brief, processToken) {
|
|
498
|
+
let query = client
|
|
349
499
|
.from('panel3_runs')
|
|
350
500
|
.update({ pid, ...(brief === undefined ? {} : { brief }) })
|
|
351
|
-
.eq('id', runId)
|
|
352
|
-
|
|
501
|
+
.eq('id', runId);
|
|
502
|
+
if (processToken !== undefined)
|
|
503
|
+
query = query.eq('process_token', processToken);
|
|
504
|
+
const written = await returned(query.select('id'), 'record what is running', `run ${runId}`);
|
|
505
|
+
if (written.length === 0)
|
|
506
|
+
throw new Error(`could not record what is running for run ${runId}: its activation has ended`);
|
|
353
507
|
}
|
|
354
508
|
/**
|
|
355
509
|
* ═══ A RUN GIVEN UP ON: ENDED, ITS TURNS HANDED BACK, ITS CARD IN HAND. ═══
|
|
@@ -364,15 +518,19 @@ async function recordProcess(client, runId, pid, brief) {
|
|
|
364
518
|
* there first. It is a fact about the record rather than a failure, so it does
|
|
365
519
|
* not go through `returned()`, exactly as the answer's own null does not.
|
|
366
520
|
*/
|
|
367
|
-
async function giveUp(client, runId, reason) {
|
|
521
|
+
async function giveUp(client, runId, reason, processToken) {
|
|
368
522
|
const { data, error } = await client
|
|
369
|
-
.rpc('panel3_give_up', {
|
|
523
|
+
.rpc('panel3_give_up', {
|
|
524
|
+
p_run_id: runId,
|
|
525
|
+
p_reason: reason,
|
|
526
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
527
|
+
});
|
|
370
528
|
if (error)
|
|
371
529
|
throw new Error(`could not give up run ${runId}: ${error.message}`);
|
|
372
530
|
/* THE RUN IS OVER, so whatever it read is dropped. Every ending does this —
|
|
373
531
|
here, `failRun` and `writeAnswer` — because a daemon stays up for days and
|
|
374
532
|
has no business holding Tuesday's secret. */
|
|
375
|
-
forgetSecrets(runId);
|
|
533
|
+
forgetSecrets(processToken === undefined ? runId : `${runId}:${processToken}`);
|
|
376
534
|
return data;
|
|
377
535
|
}
|
|
378
536
|
/**
|
|
@@ -411,7 +569,7 @@ async function giveUp(client, runId, reason) {
|
|
|
411
569
|
* a run can be the last thing live on it — but no turn is written, exactly as
|
|
412
570
|
* none is written for a run that stopped to ask.
|
|
413
571
|
*/
|
|
414
|
-
async function writeAnswer(client, runId, cardId, text) {
|
|
572
|
+
async function writeAnswer(client, runId, cardId, text, processToken) {
|
|
415
573
|
/* ═══ THE SECOND OF THE TWO CHOKEPOINTS THE CREDENTIAL RULE RESTS ON. ═══
|
|
416
574
|
This is the ONE path an agent's own words take to a hosted row — a turn on
|
|
417
575
|
the card at levels 1 and 2, and a level 3's `panel3_runs.report`, which
|
|
@@ -419,7 +577,11 @@ async function writeAnswer(client, runId, cardId, text) {
|
|
|
419
577
|
`buildServer`. Identity for a run that read no credential, so every existing
|
|
420
578
|
exact-string assertion is byte-identical. */
|
|
421
579
|
const { data: turnId, error } = await client
|
|
422
|
-
.rpc('panel3_answer', {
|
|
580
|
+
.rpc('panel3_answer', {
|
|
581
|
+
p_run_id: runId,
|
|
582
|
+
p_body: text === null ? null : redactSecrets(processToken === undefined ? runId : `${runId}:${processToken}`, text),
|
|
583
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
584
|
+
});
|
|
423
585
|
if (error) {
|
|
424
586
|
/* Thrown, not settled as failed. The run stays `running` with a process
|
|
425
587
|
that has now exited, so recovery reaps it: at level 1 the turns are
|
|
@@ -433,7 +595,7 @@ async function writeAnswer(client, runId, cardId, text) {
|
|
|
433
595
|
credential again through `get_credential`. What it is handed to start from —
|
|
434
596
|
its own report, and the card — went through this same substitution, so there
|
|
435
597
|
is nothing left for a stale entry to protect. */
|
|
436
|
-
forgetSecrets(runId);
|
|
598
|
+
forgetSecrets(processToken === undefined ? runId : `${runId}:${processToken}`);
|
|
437
599
|
if (turnId === null) {
|
|
438
600
|
/* NO TURN WAS WRITTEN, and there are FOUR reasons, which mean different
|
|
439
601
|
things and must not be printed as one line.
|
|
@@ -469,6 +631,9 @@ async function writeAnswer(client, runId, cardId, text) {
|
|
|
469
631
|
else if (run.state !== 'finished') {
|
|
470
632
|
said(`run ${runId} was ${run.state}, so its answer was not written to card ${cardId}`);
|
|
471
633
|
}
|
|
634
|
+
else if (run.level === 1 && run.conversationOwnerId !== null) {
|
|
635
|
+
out(`settled card ${cardId} run ${runId} assigned the conversation, so it wrote nothing here`);
|
|
636
|
+
}
|
|
472
637
|
else if (text === null) {
|
|
473
638
|
out(`settled card ${cardId} run ${runId} answered a question, so it wrote nothing here`);
|
|
474
639
|
}
|
|
@@ -481,16 +646,32 @@ async function writeAnswer(client, runId, cardId, text) {
|
|
|
481
646
|
away. */
|
|
482
647
|
said(`run ${runId} had already ended, so its answer was not written to card ${cardId}`);
|
|
483
648
|
}
|
|
484
|
-
return;
|
|
649
|
+
return ownerSettlementAccepted(run, processToken);
|
|
485
650
|
}
|
|
486
651
|
out(`answered card ${cardId} run ${runId} ${text?.length ?? 0} characters`);
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
export function ownerSettlementAccepted(run, processToken) {
|
|
655
|
+
return (run.state === 'asked' || run.state === 'finished')
|
|
656
|
+
&& (processToken === undefined || run.processToken === processToken);
|
|
487
657
|
}
|
|
488
658
|
/** What the record says a run is now, and at what level. Read only to say the
|
|
489
659
|
* right sentence about something that has already happened; nothing branches on
|
|
490
660
|
* it that could have gone the other way. */
|
|
491
661
|
async function runNow(client, runId) {
|
|
492
|
-
const runs = await returned(client
|
|
493
|
-
|
|
662
|
+
const runs = await returned(client
|
|
663
|
+
.from('panel3_runs')
|
|
664
|
+
.select('state, level, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
|
|
665
|
+
.eq('id', runId), 'read', `the state of run ${runId}`);
|
|
666
|
+
const run = runs[0];
|
|
667
|
+
return run
|
|
668
|
+
? {
|
|
669
|
+
state: run.state,
|
|
670
|
+
level: run.level,
|
|
671
|
+
conversationOwnerId: run.card?.conversation_run_id ?? null,
|
|
672
|
+
processToken: run.process_token,
|
|
673
|
+
}
|
|
674
|
+
: { state: 'no longer on the record', level: null, conversationOwnerId: null, processToken: null };
|
|
494
675
|
}
|
|
495
676
|
/**
|
|
496
677
|
* A run whose process failed, ended with the reason ON ITS OWN COLUMN, and its
|
|
@@ -499,7 +680,7 @@ async function runNow(client, runId) {
|
|
|
499
680
|
* ═══ NEVER INTO `report`, WHICH IS THE RUN'S OWN WORDS. ═══ `report` is what a
|
|
500
681
|
* respawn is handed, and this build starts settled runs again — an answered
|
|
501
682
|
* question does exactly that — so a reason written there would destroy the state
|
|
502
|
-
* the next attempt was going to resume from, and `
|
|
683
|
+
* the next attempt was going to resume from, and `cs show` would print the
|
|
503
684
|
* daemon's sentence under a heading that says the run wrote it. One owner per
|
|
504
685
|
* column: the run writes `report`, whoever ended it writes `failed_because`.
|
|
505
686
|
*
|
|
@@ -560,7 +741,7 @@ async function setCardState(client, cardId, state) {
|
|
|
560
741
|
* ═══ THERE IS NOTHING TO UNDO HERE, AND THAT IS THE POINT. ═══ The take wrote
|
|
561
742
|
* the run row in the same statement that leased the turns, so this function is
|
|
562
743
|
* never in the position of holding a lease it cannot honour: whatever it fails
|
|
563
|
-
* at, the row exists, `
|
|
744
|
+
* at, the row exists, `cs show` can see it, and recovery can reach it. The
|
|
564
745
|
* earlier version wrote the row two round trips later and had to hand the turns
|
|
565
746
|
* back on failure — which covered a thrown error and not a kill, and a kill in
|
|
566
747
|
* that window stranded the card forever.
|
|
@@ -583,7 +764,43 @@ async function answerCard(client, tools, machineId, cardId, turns) {
|
|
|
583
764
|
attachments read carries the same rule: a failed read here must not read
|
|
584
765
|
as "nothing is attached", which is a different card than the one that was
|
|
585
766
|
actually sent. */
|
|
586
|
-
|
|
767
|
+
/* ═══ AND A FAILURE HERE ENDS THE RUN, RATHER THAN LEAVING IT RUNNING WITH NO
|
|
768
|
+
PROCESS. ═══ The take already wrote the run row in the statement that leased
|
|
769
|
+
the turns, so a throw between here and `startAgent` leaves a run reading
|
|
770
|
+
`running` with a null pid and nothing on stderr the person can see.
|
|
771
|
+
`recoverStranded` then reads that as a machine that went away, hands the
|
|
772
|
+
message back, and `panel3_take_turns` leases it to A BRAND NEW RUN whose
|
|
773
|
+
attempts start again at one — so a permanent failure, such as a read this
|
|
774
|
+
build cannot make against the current schema, repeats forever while the card
|
|
775
|
+
says Working and never says why. This is the sixth of `endRun`'s endings and
|
|
776
|
+
the last one that was missing: `resumeRun` and `startRearmed` already end
|
|
777
|
+
their two post-claim failures this way for exactly this reason.
|
|
778
|
+
THE REASON IS SHAREABLE. All three reads are `returned()` calls against the
|
|
779
|
+
database, whose messages name tables and columns and never a local path, so
|
|
780
|
+
constraint 6 is satisfied without a level fork here. */
|
|
781
|
+
let brief;
|
|
782
|
+
/* WHERE IT RUNS AND WHAT IT IS TOLD ABOUT THE CODEBASES, THROUGH THE SAME
|
|
783
|
+
SEAM AS EVERY OTHER SPAWN. A level 1 run works in an empty directory of the
|
|
784
|
+
user's own, and it is the fifth start site rather than a special case: the
|
|
785
|
+
generated git block reaches every level, and one place resolving it is what
|
|
786
|
+
makes that true without four copies of the read. */
|
|
787
|
+
let where;
|
|
788
|
+
/* THE MANDATE IS READ IN THE SAME WINDOW AND UNDER THE SAME RULE. A launcher
|
|
789
|
+
is an agent like any other and gets the project's standing rules before it
|
|
790
|
+
decides anything, and a read that fails must not read as "this project has
|
|
791
|
+
no rules" — that is a different project than the one the person is on. So it
|
|
792
|
+
joins the three reads above inside this ending rather than beside it. */
|
|
793
|
+
let rules;
|
|
794
|
+
try {
|
|
795
|
+
brief = briefFor(turns[0].card_title, turns, await receiptsFor(client, cardId), await attachmentsFor(client, cardId), await codebasesForRun(client, runId));
|
|
796
|
+
rules = await standingRulesFor(client, runId);
|
|
797
|
+
where = await workingDirectory(client, runId, LEVEL, false);
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
801
|
+
await endRun(client, LEVEL, runId, cardId, why);
|
|
802
|
+
throw new Error(`NO AGENT IS RUNNING: ${why}`);
|
|
803
|
+
}
|
|
587
804
|
/* ═══ THE RUN ID IS ON THE URL, AND THAT IS THE WHOLE OF WHAT THE AGENT IS
|
|
588
805
|
TOLD ABOUT ITS OWN STANDING. ═══ The tools server reads the level off the
|
|
589
806
|
run row this id names, so the daemon does not tell the child what it may do
|
|
@@ -593,9 +810,12 @@ async function answerCard(client, tools, machineId, cardId, turns) {
|
|
|
593
810
|
/* AN EMPTY DIRECTORY OF THE USER'S OWN, because level 1 has no code tool to
|
|
594
811
|
use a real one with, and the daemon's inherited cwd under a launchd login
|
|
595
812
|
item is the filesystem root. */
|
|
596
|
-
const started = startAgent(brief, LEVEL, tools.urlFor(runId),
|
|
813
|
+
const started = startAgent(withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd);
|
|
597
814
|
out(`run ${runId} level ${LEVEL} ${started.pid ? `pid ${started.pid}` : 'no process'}`);
|
|
598
815
|
try {
|
|
816
|
+
/* THE BRIEF, NOT WHAT THE PROCESS WAS HANDED. The rules are current at the
|
|
817
|
+
activation and the brief is immutable, so storing the composed string
|
|
818
|
+
would freeze one inside the other and every respawn would replay it. */
|
|
599
819
|
await recordProcess(client, runId, started.pid, brief);
|
|
600
820
|
}
|
|
601
821
|
catch (error) {
|
|
@@ -603,7 +823,7 @@ async function answerCard(client, tools, machineId, cardId, turns) {
|
|
|
603
823
|
// agent is not killed over it.
|
|
604
824
|
said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
|
|
605
825
|
}
|
|
606
|
-
return settle(client, tools, machineId, LEVEL, runId, cardId, started);
|
|
826
|
+
return settle(client, tools, machineId, LEVEL, runId, cardId, started, false);
|
|
607
827
|
}
|
|
608
828
|
/**
|
|
609
829
|
* WHICH PROJECT A RUN'S CARD IS FILED UNDER, or null when the card has none.
|
|
@@ -620,7 +840,7 @@ async function answerCard(client, tools, machineId, cardId, turns) {
|
|
|
620
840
|
* project's code.
|
|
621
841
|
*/
|
|
622
842
|
async function projectOfRun(client, runId) {
|
|
623
|
-
const rows = await returned(client.from('panel3_runs').select('card:panel3_cards(project_id)').eq('id', runId), 'read', 'which project this run belongs to');
|
|
843
|
+
const rows = await returned(client.from('panel3_runs').select('card:panel3_cards!panel3_runs_card_id_fkey(project_id)').eq('id', runId), 'read', 'which project this run belongs to');
|
|
624
844
|
return rows[0]?.card?.project_id ?? null;
|
|
625
845
|
}
|
|
626
846
|
async function codebasesForRun(client, runId) {
|
|
@@ -630,7 +850,7 @@ async function codebasesForRun(client, runId) {
|
|
|
630
850
|
async function codebaseOfRun(client, runId) {
|
|
631
851
|
const rows = await returned(client
|
|
632
852
|
.from('panel3_runs')
|
|
633
|
-
.select('codebase_id, card:panel3_cards(project_id)')
|
|
853
|
+
.select('codebase_id, card:panel3_cards!panel3_runs_card_id_fkey(project_id)')
|
|
634
854
|
.eq('id', runId), 'read', `which codebase run ${runId} belongs to`);
|
|
635
855
|
const row = rows[0];
|
|
636
856
|
if (!row?.codebase_id || !row.card?.project_id) {
|
|
@@ -643,6 +863,179 @@ async function codebaseOfRun(client, runId) {
|
|
|
643
863
|
}
|
|
644
864
|
return codebase;
|
|
645
865
|
}
|
|
866
|
+
/**
|
|
867
|
+
* ═══ THE CARD'S OWN COPY, AND THE BRANCH IT IS ON, STAMPED BEFORE IT IS USED.
|
|
868
|
+
* ═══
|
|
869
|
+
*
|
|
870
|
+
* The card is the unit of isolation, so the branch is derived from the card and
|
|
871
|
+
* every run on that card resolves to the same one. A run that finds a branch
|
|
872
|
+
* already stamped by a sibling takes it rather than deriving again: the
|
|
873
|
+
* derivation is pure today and stops being pure the moment the pattern becomes
|
|
874
|
+
* a setting, and one card on two branches is the defect this whole slice exists
|
|
875
|
+
* to prevent.
|
|
876
|
+
*
|
|
877
|
+
* ═══ THE STAMP IS WRITTEN HERE, WHERE ITS FAILURE CAN STOP THE RUN. ═══ Not in
|
|
878
|
+
* `recordProcess`, whose failure is deliberately non-fatal at all five sites: a
|
|
879
|
+
* stamp allowed to silently not happen cannot carry "a resumed run returns to
|
|
880
|
+
* the same copy". This is before `startAgent`, so a failure to write it is a run
|
|
881
|
+
* that never started rather than a run whose copy nobody can find again.
|
|
882
|
+
*/
|
|
883
|
+
async function cardWorktree(client, runId, codebase) {
|
|
884
|
+
const rows = await returned(client.from('panel3_runs')
|
|
885
|
+
.select('card_id, branch, base, card:panel3_cards!panel3_runs_card_id_fkey(title)')
|
|
886
|
+
.eq('id', runId), 'read', `which card and branch run ${runId} is on`);
|
|
887
|
+
const row = rows[0];
|
|
888
|
+
if (!row)
|
|
889
|
+
throw new Error(`run ${runId} is not on the record any more`);
|
|
890
|
+
/* THE CARD'S OWN ANSWER FIRST, from whichever of its runs has one. Asked only
|
|
891
|
+
when this run has none of its own, so a resumed run costs one read. The
|
|
892
|
+
BASE travels with the branch, in one row and one read: they were written
|
|
893
|
+
together and a card cut from `develop` may not be told later that it goes
|
|
894
|
+
back to `main` because a teammate changed the setting. */
|
|
895
|
+
const sibling = row.branch !== null ? null : (await returned(client.from('panel3_runs')
|
|
896
|
+
.select('branch, base')
|
|
897
|
+
.eq('card_id', row.card_id)
|
|
898
|
+
.not('branch', 'is', null)
|
|
899
|
+
.limit(1), 'read', `the branch card ${row.card_id} is working on`))[0] ?? null;
|
|
900
|
+
const stamped = {
|
|
901
|
+
branch: row.branch ?? sibling?.branch ?? null,
|
|
902
|
+
base: row.base ?? sibling?.base ?? null,
|
|
903
|
+
};
|
|
904
|
+
/* ═══ THE TEAM'S RULES, READ AT THE START AND NOT CARRIED. ═══ Same argument
|
|
905
|
+
as the standing rules beside them: a rule edited while a conversation is
|
|
906
|
+
running has to govern the rest of it, and the only way that is true is if
|
|
907
|
+
the read happens here rather than once, at dispatch. */
|
|
908
|
+
/* THE PROJECT COMES OFF THE RUN'S OWN CARD, which is where the codebase came
|
|
909
|
+
from too: the rules are the organization's, and the organization is reached
|
|
910
|
+
through the project rather than through the codebase row, which `panel3/`
|
|
911
|
+
may not name. */
|
|
912
|
+
const projectId = await projectOfRun(client, runId);
|
|
913
|
+
if (!projectId) {
|
|
914
|
+
/* UNREACHABLE THROUGH `codebaseOfRun`, WHICH REFUSES A RUN WITH NO PROJECT,
|
|
915
|
+
and said out loud rather than papered over with an empty id: the rules are
|
|
916
|
+
the organization's, and there is no organization without a project. */
|
|
917
|
+
throw new Error('This code work is not filed under a project, so its codebase has no rules.');
|
|
918
|
+
}
|
|
919
|
+
const rules = await gitRulesFor(client, codebase.id, projectId);
|
|
920
|
+
const { folder, branch, base, source } = worktreeForCard(codebase, row.card_id, row.card?.title ?? '', stamped, hostname(), { baseBranch: rules.base_branch, branchPattern: rules.branchPattern });
|
|
921
|
+
if (row.branch !== branch || row.base !== base) {
|
|
922
|
+
await returned(client.from('panel3_runs').update({ branch, base }).eq('id', runId).select('id'), 'record which branch this run is on', `run ${runId}`);
|
|
923
|
+
}
|
|
924
|
+
await refreshBaseProtection(client, codebase, rules, source, base);
|
|
925
|
+
return {
|
|
926
|
+
cwd: folder,
|
|
927
|
+
block: workingRules({
|
|
928
|
+
codebase: codebase.name,
|
|
929
|
+
branch,
|
|
930
|
+
base,
|
|
931
|
+
landing: rules.landing,
|
|
932
|
+
branchPattern: rules.branchPattern,
|
|
933
|
+
}),
|
|
934
|
+
/* ═══ HANDED BACK RATHER THAN DISCARDED. ═══ The landing needs the folder,
|
|
935
|
+
the branch, the base and the repository the copy was made from, all of
|
|
936
|
+
which have just been resolved here. Resolving them again at the ending
|
|
937
|
+
would be a second answer to one question, free to disagree with this one. */
|
|
938
|
+
card: { folder, branch, base, source, landing: rules.landing, codebaseName: codebase.name },
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* ═══ WHETHER THE BASE BRANCH IS PROTECTED, ASKED WHILE THIS MACHINE IS ALREADY
|
|
943
|
+
* HOLDING THE REPOSITORY OPEN. ═══
|
|
944
|
+
*
|
|
945
|
+
* The browser cannot answer it and the product stores no platform credential, so
|
|
946
|
+
* the machine with the checkout answers it through the developer's own `gh`.
|
|
947
|
+
* Asked only when the record has no answer, so a codebase whose protection is
|
|
948
|
+
* known costs nothing, and never turned into a SETTING: it is an observation
|
|
949
|
+
* that feeds the detected default, and `landing` stays null until a person
|
|
950
|
+
* chooses.
|
|
951
|
+
*
|
|
952
|
+
* ═══ SAID, NEVER FATAL. ═══ This is a fact about the remote, not about the
|
|
953
|
+
* work. A card that could not record an observation still runs.
|
|
954
|
+
*/
|
|
955
|
+
async function refreshBaseProtection(client, codebase, rules, source, base) {
|
|
956
|
+
if (rules.base_protected !== null)
|
|
957
|
+
return;
|
|
958
|
+
try {
|
|
959
|
+
const observed = detectBaseProtection(source, base);
|
|
960
|
+
if (observed !== null)
|
|
961
|
+
await recordBaseProtection(client, codebase.id, observed);
|
|
962
|
+
}
|
|
963
|
+
catch (error) {
|
|
964
|
+
said(`could not check whether ${base} is protected: ${error instanceof Error ? error.message : String(error)}`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* ═══ WHAT THE COORDINATOR IS TOLD ABOUT EACH CODEBASE IT MAY SEND WORK TO. ═══
|
|
969
|
+
*
|
|
970
|
+
* Story 6 scenario 4: levels 1, 2 and 3 each receive the block verbatim. A level
|
|
971
|
+
* 1 run has no codebase of its own by design, and the block is codebase-scoped,
|
|
972
|
+
* so at level 1 it is rendered once PER REGISTERED CODEBASE. That is the same
|
|
973
|
+
* text rather than a summary of it, and it is what stops a coordinator writing a
|
|
974
|
+
* brief that contradicts the rules the worker it dispatches will be handed.
|
|
975
|
+
*
|
|
976
|
+
* ═══ AND IT CARRIES THE ONE QUESTION ONLY THE COORDINATOR CAN ASK. ═══ Story 3
|
|
977
|
+
* scenario 3: a codebase whose base branch is genuinely ambiguous has to be
|
|
978
|
+
* settled by a person, once, and the daemon cannot ask anybody. The coordinator
|
|
979
|
+
* can, it holds `ask_question` already, and it needs the fact BEFORE it
|
|
980
|
+
* dispatches. So the fact and the options arrive here, at the activation, beside
|
|
981
|
+
* the block.
|
|
982
|
+
*
|
|
983
|
+
* ═══ NOT THROUGH `briefFor`. ═══ That output is `panel3_runs.brief`, which is
|
|
984
|
+
* immutable and replayed at every respawn, so a coordinator resumed a week later
|
|
985
|
+
* would be told to ask about a branch somebody settled on Tuesday.
|
|
986
|
+
*/
|
|
987
|
+
async function coordinatorGitBlock(client, runId) {
|
|
988
|
+
const projectId = await projectOfRun(client, runId);
|
|
989
|
+
/* A CARD FILED UNDER NO PROJECT HAS NO CODEBASES AND NO RULES, which is a
|
|
990
|
+
fact rather than a failure: level 1 answers plenty of cards that never touch
|
|
991
|
+
code. */
|
|
992
|
+
if (!projectId)
|
|
993
|
+
return [];
|
|
994
|
+
const codebases = await listCodebases(client, projectId);
|
|
995
|
+
if (codebases.length === 0)
|
|
996
|
+
return [];
|
|
997
|
+
const blocks = [];
|
|
998
|
+
for (const codebase of codebases) {
|
|
999
|
+
const rules = await gitRulesFor(client, codebase.id, projectId);
|
|
1000
|
+
/* A CODEBASE THIS MACHINE HAS NOT LOCATED ANSWERS NEITHER WAY, and is left
|
|
1001
|
+
out rather than guessed at: `briefFor` already tells the coordinator which
|
|
1002
|
+
codebases are located here, and inventing rules for one nobody can see
|
|
1003
|
+
would be a block about a repository this run cannot reach. */
|
|
1004
|
+
const state = baseBranchState(codebase, rules.base_branch);
|
|
1005
|
+
if (state === null)
|
|
1006
|
+
continue;
|
|
1007
|
+
blocks.push([
|
|
1008
|
+
...workingRules({
|
|
1009
|
+
codebase: codebase.name,
|
|
1010
|
+
branch: null,
|
|
1011
|
+
base: 'settled' in state ? state.settled : null,
|
|
1012
|
+
landing: rules.landing,
|
|
1013
|
+
branchPattern: rules.branchPattern,
|
|
1014
|
+
}),
|
|
1015
|
+
...('candidates' in state ? [
|
|
1016
|
+
`Nobody has said which branch finished work in ${codebase.name} should go back to, and this`,
|
|
1017
|
+
'machine cannot tell. Before you send anybody into it, ask the person with `ask_question`,',
|
|
1018
|
+
'once, offering these as the options and using no git word except "branch":',
|
|
1019
|
+
state.candidates.join(', '),
|
|
1020
|
+
`Then record their answer with \`set_main_branch\` for codebase id ${codebase.id}. It is`,
|
|
1021
|
+
'stored on the codebase, so nobody is asked again.',
|
|
1022
|
+
] : []),
|
|
1023
|
+
]);
|
|
1024
|
+
}
|
|
1025
|
+
return blocks.flatMap((block, index) => (index === 0 ? block : ['', ...block]));
|
|
1026
|
+
}
|
|
1027
|
+
/** One directory rule for every spawn whose level and ownership are known. */
|
|
1028
|
+
async function workingDirectory(client, runId, level, isOwner, knownCodebase) {
|
|
1029
|
+
if (level === 1) {
|
|
1030
|
+
return { cwd: scratchDir(), block: await coordinatorGitBlock(client, runId), card: null };
|
|
1031
|
+
}
|
|
1032
|
+
if (knownCodebase === null) {
|
|
1033
|
+
if (level === 2 && isOwner)
|
|
1034
|
+
return { cwd: scratchDir(), block: [], card: null };
|
|
1035
|
+
throw new Error('This code work does not name a registered project codebase.');
|
|
1036
|
+
}
|
|
1037
|
+
return cardWorktree(client, runId, knownCodebase ?? await codebaseOfRun(client, runId));
|
|
1038
|
+
}
|
|
646
1039
|
/**
|
|
647
1040
|
* ═══ ONE AGENT, STARTED UNDER ANOTHER THAT IS STILL RUNNING. ═══
|
|
648
1041
|
*
|
|
@@ -658,13 +1051,14 @@ async function codebaseOfRun(client, runId) {
|
|
|
658
1051
|
* Then the row, then the process, then the pid — constraint 8, in the only order
|
|
659
1052
|
* that satisfies it.
|
|
660
1053
|
*/
|
|
661
|
-
async function startChild(client, tools, machineId, parentRunId, brief, codebase) {
|
|
1054
|
+
async function startChild(client, tools, machineId, parentRunId, brief, codebase, parentProcessToken) {
|
|
662
1055
|
const { data, error } = await client.rpc('panel3_dispatch', {
|
|
663
1056
|
p_parent_run_id: parentRunId,
|
|
664
1057
|
p_brief: brief,
|
|
665
1058
|
p_machine_id: machineId,
|
|
666
|
-
p_codebase_id: codebase
|
|
667
|
-
p_codebase_label: codebase
|
|
1059
|
+
p_codebase_id: codebase?.id ?? null,
|
|
1060
|
+
p_codebase_label: codebase?.name ?? null,
|
|
1061
|
+
...(parentProcessToken === undefined ? {} : { p_process_token: parentProcessToken }),
|
|
668
1062
|
});
|
|
669
1063
|
if (error)
|
|
670
1064
|
throw new Error(`could not start an agent under run ${parentRunId}: ${error.message}`);
|
|
@@ -673,8 +1067,8 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
|
|
|
673
1067
|
/* NOTHING WAS WRITTEN AND NOTHING IS RUNNING, and the two reasons are said
|
|
674
1068
|
together because the caller cannot tell them apart from here and both mean
|
|
675
1069
|
the same thing to it. */
|
|
676
|
-
throw new Error(`NO AGENT WAS STARTED and nothing was written: run ${parentRunId} has ended,
|
|
677
|
-
+ '
|
|
1070
|
+
throw new Error(`NO AGENT WAS STARTED and nothing was written: run ${parentRunId} has ended, is already as `
|
|
1071
|
+
+ 'deep as anything may be sent from, or this conversation already has its owner. Exit now.');
|
|
678
1072
|
}
|
|
679
1073
|
if (row.run_level !== 2 && row.run_level !== 3) {
|
|
680
1074
|
/* UNREACHABLE, AND STILL SETTLED. `panel3_dispatch` writes `parent.level + 1`
|
|
@@ -684,20 +1078,31 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
|
|
|
684
1078
|
recovery sweep wait out the pid grace window to conclude what is already
|
|
685
1079
|
known. */
|
|
686
1080
|
const why = `run ${row.run_id} was written at level ${row.run_level}, which cannot be spawned`;
|
|
687
|
-
await giveUp(client, row.run_id, why);
|
|
1081
|
+
await giveUp(client, row.run_id, why, row.process_token ?? undefined);
|
|
688
1082
|
throw new Error(why);
|
|
689
1083
|
}
|
|
690
1084
|
const level = row.run_level;
|
|
691
|
-
let
|
|
1085
|
+
let where;
|
|
1086
|
+
let prompt;
|
|
1087
|
+
/* READ AGAINST THE CHILD'S OWN RUN ROW, NOT THE PARENT'S. The row already
|
|
1088
|
+
exists (`panel3_dispatch` wrote it above) and it carries this child's
|
|
1089
|
+
codebase, which is what decides which codebase-scoped rules it is under. */
|
|
1090
|
+
let rules;
|
|
692
1091
|
try {
|
|
693
|
-
|
|
1092
|
+
where = await workingDirectory(client, row.run_id, level, level === 2, codebase);
|
|
1093
|
+
prompt = level === 2
|
|
1094
|
+
? await initialOwnerPrompt(client, row.run_card_id, parentRunId, brief)
|
|
1095
|
+
: brief;
|
|
1096
|
+
rules = await standingRulesFor(client, row.run_id);
|
|
694
1097
|
}
|
|
695
1098
|
catch (error) {
|
|
696
1099
|
const why = error instanceof Error ? error.message : String(error);
|
|
697
|
-
await giveUp(client, row.run_id, why);
|
|
1100
|
+
await giveUp(client, row.run_id, why, row.process_token ?? undefined);
|
|
698
1101
|
throw new Error(`NO AGENT IS RUNNING: ${why}`);
|
|
699
1102
|
}
|
|
700
|
-
const
|
|
1103
|
+
const processToken = row.process_token ?? undefined;
|
|
1104
|
+
const isOwner = level === 2;
|
|
1105
|
+
const started = startAgent(withStandingRules(rules, prompt, where.block), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined);
|
|
701
1106
|
if (started.pid === null) {
|
|
702
1107
|
/* THERE IS A ROW AND THERE IS NO PROCESS, which is the one shape the record
|
|
703
1108
|
must never be left in quietly. The answer is already settled — nothing ran
|
|
@@ -705,12 +1110,12 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
|
|
|
705
1110
|
and the tool call fails saying no agent was started. */
|
|
706
1111
|
const answer = await started.answered;
|
|
707
1112
|
const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
|
|
708
|
-
await giveUp(client, row.run_id, reason);
|
|
1113
|
+
await giveUp(client, row.run_id, reason, processToken);
|
|
709
1114
|
throw new Error(`NO AGENT IS RUNNING: ${reason}`);
|
|
710
1115
|
}
|
|
711
1116
|
out(`dispatch run ${row.run_id} level ${level} under ${parentRunId} pid ${started.pid}`);
|
|
712
1117
|
try {
|
|
713
|
-
await recordProcess(client, row.run_id, started.pid);
|
|
1118
|
+
await recordProcess(client, row.run_id, started.pid, undefined, processToken);
|
|
714
1119
|
}
|
|
715
1120
|
catch (error) {
|
|
716
1121
|
// Said, not fatal, exactly as at level 1: the agent is running and killing
|
|
@@ -719,7 +1124,9 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
|
|
|
719
1124
|
}
|
|
720
1125
|
return {
|
|
721
1126
|
runId: row.run_id,
|
|
722
|
-
settled: settle(client, tools, machineId, level, row.run_id, row.run_card_id, started
|
|
1127
|
+
settled: settle(client, tools, machineId, level, row.run_id, row.run_card_id, started, true, processToken, isOwner && processToken
|
|
1128
|
+
? ownerSessionLifecycle(started, row.run_id, harness(), processToken)
|
|
1129
|
+
: undefined),
|
|
723
1130
|
};
|
|
724
1131
|
}
|
|
725
1132
|
/**
|
|
@@ -763,11 +1170,13 @@ async function attemptsSoFar(client, runId) {
|
|
|
763
1170
|
* ends through `panel3_end_run`, which hands no turns back, and the card says
|
|
764
1171
|
* failed.
|
|
765
1172
|
*
|
|
766
|
-
* ═══ AND THE FORK LIVES HERE AND NOWHERE ELSE. ═══
|
|
767
|
-
* way: the settle,
|
|
768
|
-
* and `
|
|
769
|
-
* drift, and it drifted the first time it could:
|
|
770
|
-
* same pair of endings and only one pair was looked
|
|
1173
|
+
* ═══ AND THE FORK LIVES HERE AND NOWHERE ELSE. ═══ SIX places end a run this
|
|
1174
|
+
* way: the settle, the two failures after the claim in each of `resumeRun` and
|
|
1175
|
+
* `startRearmed`, and the pre-spawn failure in `answerCard`. A copy of this rule
|
|
1176
|
+
* in each is six places for it to drift, and it drifted the first time it could:
|
|
1177
|
+
* those two functions hold the same pair of endings and only one pair was looked
|
|
1178
|
+
* at. `answerCard`'s was missing outright, which is how a build that could not
|
|
1179
|
+
* read a card's project turned every take into a silent take/recover loop.
|
|
771
1180
|
*
|
|
772
1181
|
* ALL FIVE CAN SEE LEVEL 1. `resumeRun` serving the retry is what opened its
|
|
773
1182
|
* two. `startRearmed`'s were always reachable, because a re-arm has always
|
|
@@ -834,7 +1243,44 @@ async function endRun(client, level, runId, cardId, why) {
|
|
|
834
1243
|
* cannot end the same way. What `answerCard` always did is what level 1 still
|
|
835
1244
|
* does, and the only thing that has moved is where it is written.
|
|
836
1245
|
*/
|
|
837
|
-
function
|
|
1246
|
+
function ownerSessionLifecycle(started, ownerId, ownerHarness, processToken, expectedSessionId) {
|
|
1247
|
+
const pending = started.session.then((nativeSessionId) => {
|
|
1248
|
+
if (nativeSessionId === null)
|
|
1249
|
+
return false;
|
|
1250
|
+
if (expectedSessionId && nativeSessionId !== expectedSessionId) {
|
|
1251
|
+
said(`the resumed harness returned a different conversation for owner ${ownerId}; it will not be reused`);
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
try {
|
|
1255
|
+
writeOwnerSession({
|
|
1256
|
+
ownerId,
|
|
1257
|
+
harness: ownerHarness,
|
|
1258
|
+
nativeSessionId,
|
|
1259
|
+
processToken,
|
|
1260
|
+
state: 'pending',
|
|
1261
|
+
});
|
|
1262
|
+
return true;
|
|
1263
|
+
}
|
|
1264
|
+
catch (error) {
|
|
1265
|
+
said(`could not save the local conversation for owner ${ownerId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
// Start observing immediately; Codex reports thread.started before its answer.
|
|
1270
|
+
void pending;
|
|
1271
|
+
return {
|
|
1272
|
+
established: async () => {
|
|
1273
|
+
if (await pending)
|
|
1274
|
+
establishOwnerSession(ownerId, processToken);
|
|
1275
|
+
},
|
|
1276
|
+
failed: async () => {
|
|
1277
|
+
// A failed or rejected generation stays pending and therefore cannot be
|
|
1278
|
+
// resumed. Reconciliation is the sole owner of stable-state deletion.
|
|
1279
|
+
await pending;
|
|
1280
|
+
},
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
function settle(client, tools, machineId, level, runId, cardId, started, speaksToTheCard = true, processToken, ownerSession) {
|
|
838
1284
|
return started.answered.then(async (answer) => {
|
|
839
1285
|
/* ═══ A RUN THAT STOPPED TO ASK DID NOT DIE, WHATEVER THE HARNESS PRINTED
|
|
840
1286
|
ON ITS WAY OUT. ═══
|
|
@@ -860,11 +1306,27 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
|
|
|
860
1306
|
into an ordinary ending, and then handed to `writeAnswer` — which already
|
|
861
1307
|
owns this case and already prints its sentence — rather than to a second
|
|
862
1308
|
ending written here beside it. */
|
|
863
|
-
if (!answer.ok
|
|
864
|
-
await
|
|
865
|
-
|
|
1309
|
+
if (!answer.ok) {
|
|
1310
|
+
const run = await runNow(client, runId);
|
|
1311
|
+
if (run.state === 'asked') {
|
|
1312
|
+
const accepted = await writeAnswer(client, runId, cardId, null, processToken);
|
|
1313
|
+
if (accepted)
|
|
1314
|
+
await ownerSession?.established();
|
|
1315
|
+
else
|
|
1316
|
+
await ownerSession?.failed();
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
/* Level 1's only successful result is the owner pointer written by
|
|
1320
|
+
dispatch. Its stdout is deliberately hidden, and Codex may therefore
|
|
1321
|
+
exit cleanly without an agent message after the tool succeeds. The
|
|
1322
|
+
record, not prose the launcher was told not to write, decides success. */
|
|
1323
|
+
if (level === 1 && run.conversationOwnerId !== null) {
|
|
1324
|
+
await writeAnswer(client, runId, cardId, null, processToken);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
866
1327
|
}
|
|
867
1328
|
if (!answer.ok) {
|
|
1329
|
+
await ownerSession?.failed();
|
|
868
1330
|
/* ═══ A PROCESS THAT STARTED AND THEN DIED BADLY IS STARTED AGAIN. ═══
|
|
869
1331
|
recovery-1/ux.md, Slice 3: "the agent's harness fails on something that
|
|
870
1332
|
is nobody's fault: a rate limit, a dropped connection, a provider
|
|
@@ -904,7 +1366,9 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
|
|
|
904
1366
|
const attempts = await attemptsSoFar(client, runId);
|
|
905
1367
|
if (started.pid !== null && attempts < MAX_ATTEMPTS) {
|
|
906
1368
|
out(`retry run ${runId} attempt ${attempts} of ${MAX_ATTEMPTS} died: ${answer.reason}`);
|
|
907
|
-
const again =
|
|
1369
|
+
const again = processToken === undefined
|
|
1370
|
+
? await resumeRun(client, tools, machineId, runId, started.pid)
|
|
1371
|
+
: await activateOwner(client, tools, machineId, runId, processToken, started.pid);
|
|
908
1372
|
if (again)
|
|
909
1373
|
return again.settled;
|
|
910
1374
|
/* ═══ THE CLAIM MATCHED NOTHING, AND THAT IS NOT ALWAYS SOMEBODY ELSE
|
|
@@ -937,7 +1401,9 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
|
|
|
937
1401
|
both already read, so the count reaches a person from one place rather
|
|
938
1402
|
than two. */
|
|
939
1403
|
const why = attempts > 1 ? `${answer.reason} (after ${attempts} attempts)` : answer.reason;
|
|
940
|
-
const ended =
|
|
1404
|
+
const ended = processToken === undefined
|
|
1405
|
+
? await endRun(client, level, runId, cardId, why)
|
|
1406
|
+
: (await giveUp(client, runId, why, processToken)) !== null;
|
|
941
1407
|
if (!ended) {
|
|
942
1408
|
/* IT WAS ALREADY SETTLED, by recovery, which decided this process was
|
|
943
1409
|
gone before it said so itself, or by the person's Stop. Nothing was
|
|
@@ -954,7 +1420,11 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
|
|
|
954
1420
|
/* ═══ ITS OWN WORDS, ONTO THE CARD, THROUGH THE SAME STATEMENT EVERY LEVEL
|
|
955
1421
|
USES. ═══ Nothing reads them, nothing shortens them and nothing waits to
|
|
956
1422
|
approve them: ux.md's "whoever did the work writes the answer". */
|
|
957
|
-
await writeAnswer(client, runId, cardId, speaksToTheCard ? answer.text : null);
|
|
1423
|
+
const accepted = await writeAnswer(client, runId, cardId, speaksToTheCard ? answer.text : null, processToken);
|
|
1424
|
+
if (accepted)
|
|
1425
|
+
await ownerSession?.established();
|
|
1426
|
+
else
|
|
1427
|
+
await ownerSession?.failed();
|
|
958
1428
|
});
|
|
959
1429
|
}
|
|
960
1430
|
/**
|
|
@@ -1014,14 +1484,20 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
|
|
|
1014
1484
|
* about the failure it was holding.
|
|
1015
1485
|
*/
|
|
1016
1486
|
async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
1017
|
-
/* ═══ THE SWEEP
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1487
|
+
/* ═══ THE SWEEP CHECKS BEFORE THE CLAIM AND THE RETRY CANNOT, AND `afterPid`
|
|
1488
|
+
IS THE WHOLE OF WHAT DECIDES IT. ═══ Said once, here. See the header for
|
|
1489
|
+
both halves of the argument: a machine with no checkout must not take a run
|
|
1490
|
+
it cannot start, and the level a retry needs the answer for is not known
|
|
1491
|
+
until the claim returns.
|
|
1492
|
+
|
|
1493
|
+
═══ IT IS THE EXISTENCE CHECK AND NOTHING MORE, WHICH IS WHAT CHANGED IN
|
|
1494
|
+
worktrees-8. ═══ Resolving the working copy now CREATES a branch, a folder
|
|
1495
|
+
and a row write, and every poll tick that loses the claim race would leave
|
|
1496
|
+
that debris behind for a run it never took. So the pre-claim question stays
|
|
1497
|
+
"does this machine have this codebase at all", which is the only question
|
|
1498
|
+
this position was ever asking, and the copy is made after the claim. */
|
|
1499
|
+
if (afterPid === null)
|
|
1500
|
+
checkoutForCodebase(await codebaseOfRun(client, runId), hostname());
|
|
1025
1501
|
const { data, error } = await client.rpc('panel3_resume', {
|
|
1026
1502
|
p_run_id: runId,
|
|
1027
1503
|
p_machine_id: machineId,
|
|
@@ -1042,41 +1518,34 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
|
1042
1518
|
await giveUp(client, runId, why);
|
|
1043
1519
|
throw new Error(why);
|
|
1044
1520
|
}
|
|
1045
|
-
/*
|
|
1046
|
-
the
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
let
|
|
1050
|
-
|
|
1051
|
-
|
|
1521
|
+
/* ONE RESOLUTION FOR BOTH PATHS, AFTER THE CLAIM. It used to fork on whether
|
|
1522
|
+
the sweep had already resolved a folder before the claim; since the copy is
|
|
1523
|
+
the card's own and making it writes, both paths make it here, in the branch
|
|
1524
|
+
that can end the run when it cannot be made. */
|
|
1525
|
+
let where;
|
|
1526
|
+
try {
|
|
1527
|
+
where = await workingDirectory(client, runId, level, false);
|
|
1052
1528
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
could reach this function and is a leak now that the retry brings level
|
|
1074
|
-
1 here: handing the person's message back mints a new run with its
|
|
1075
|
-
attempts at one, which is the bound the retry is under, undone by the
|
|
1076
|
-
one path that could not start. See `endRun`. */
|
|
1077
|
-
await endRun(client, level, runId, claimed.run_card_id, why);
|
|
1078
|
-
throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
|
|
1079
|
-
}
|
|
1529
|
+
catch (error) {
|
|
1530
|
+
/* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
|
|
1531
|
+
═══ Constraint 6, and `startRearmed`'s own handling of the same two
|
|
1532
|
+
calls: `worktreeForCard()` is already careful about this and says so;
|
|
1533
|
+
`scratchDir()` is not, because it is `mkdirSync`, whose EACCES and
|
|
1534
|
+
ENOTDIR messages name the directory they failed on. So the machine's own
|
|
1535
|
+
error is kept for stderr and the record is told only what is true and
|
|
1536
|
+
shareable. */
|
|
1537
|
+
const stderrOnly = error instanceof Error ? error.message : String(error);
|
|
1538
|
+
const why = level === 1
|
|
1539
|
+
? 'this machine could not make the empty directory this runs in'
|
|
1540
|
+
: stderrOnly;
|
|
1541
|
+
/* ═══ AND IT ENDS THE WAY A RUN OF THIS LEVEL ENDS. ═══ It was
|
|
1542
|
+
`panel3_give_up` outright, which was right while only a dispatched run
|
|
1543
|
+
could reach this function and is a leak now that the retry brings level
|
|
1544
|
+
1 here: handing the person's message back mints a new run with its
|
|
1545
|
+
attempts at one, which is the bound the retry is under, undone by the
|
|
1546
|
+
one path that could not start. See `endRun`. */
|
|
1547
|
+
await endRun(client, level, runId, claimed.run_card_id, why);
|
|
1548
|
+
throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
|
|
1080
1549
|
}
|
|
1081
1550
|
/* WHAT IT SENT OTHERS TO DO, FROM THE RECORD. Level 3 has no `dispatch`, so it
|
|
1082
1551
|
has no children to have: null says that, where an empty list would say it
|
|
@@ -1089,14 +1558,36 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
|
1089
1558
|
time: ux.md's single most expensive failure. `startRearmed` reads it the
|
|
1090
1559
|
same way, for the same reason. */
|
|
1091
1560
|
const children = level === 3 ? null : await childrenOf(client, runId);
|
|
1561
|
+
/* ═══ READ AGAIN ON EVERY START, WHICH IS THE WHOLE OF CONTRACT POINT 2. ═══
|
|
1562
|
+
A resumed or retried run is handed the rules AS THEY STAND NOW, not as they
|
|
1563
|
+
stood when it first began: a rule edited while the conversation was running
|
|
1564
|
+
governs the rest of it, and a rule deleted while it was running stops
|
|
1565
|
+
applying to it. That is only true because this read happens here rather than
|
|
1566
|
+
once, at the top of the run's life.
|
|
1567
|
+
|
|
1568
|
+
═══ AND IT ENDS THE WAY THIS PATH ALREADY ENDS. ═══ The claim has already
|
|
1569
|
+
happened, so a throw here would leave a run reading `running` with no
|
|
1570
|
+
process. `endRun` with the level fork is this path's own ending (see the cwd
|
|
1571
|
+
branch above for why level 1 may not simply be given up on), and the reason
|
|
1572
|
+
is a `returned()` message naming tables and columns, which carries no local
|
|
1573
|
+
path and is therefore shareable. */
|
|
1574
|
+
let rules;
|
|
1575
|
+
try {
|
|
1576
|
+
rules = await standingRulesFor(client, runId);
|
|
1577
|
+
}
|
|
1578
|
+
catch (error) {
|
|
1579
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
1580
|
+
await endRun(client, level, runId, claimed.run_card_id, why);
|
|
1581
|
+
throw new Error(`NO AGENT IS RUNNING: ${why}`);
|
|
1582
|
+
}
|
|
1092
1583
|
const started = startAgent(
|
|
1093
1584
|
/* ═══ WHY IT DIED IS WHAT DIFFERS, AND IT IS TOLD THE TRUTH ABOUT IT. ═══
|
|
1094
1585
|
`resumePrompt` opens by saying the machine went down, which is true of the
|
|
1095
1586
|
sweep and false of a retry: the daemon that watched this harness exit is
|
|
1096
1587
|
still running. See `retryPrompt`. */
|
|
1097
|
-
afterPid === null
|
|
1588
|
+
withStandingRules(rules, afterPid === null
|
|
1098
1589
|
? resumePrompt(claimed.run_brief, claimed.run_report, children)
|
|
1099
|
-
: retryPrompt(claimed.run_brief, claimed.run_report, children), level, tools.urlFor(runId), cwd);
|
|
1590
|
+
: retryPrompt(claimed.run_brief, claimed.run_report, children), where.block), level, tools.urlFor(runId), where.cwd);
|
|
1100
1591
|
if (started.pid === null) {
|
|
1101
1592
|
/* THE CLAIM HAPPENED AND NO PROCESS DID, which is the one shape the record
|
|
1102
1593
|
must never be left in quietly. Same handling as a dispatch that could not
|
|
@@ -1144,7 +1635,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
|
1144
1635
|
* what stops one question, or one set of finished workers, spawning forever. The cost is that this cannot decline afterwards: a run claimed and not
|
|
1145
1636
|
* started would sit `running` with no process and its question already spent. So
|
|
1146
1637
|
* every failure below ends the run with its reason, which is visible on the card
|
|
1147
|
-
* and in `
|
|
1638
|
+
* and in `cs show`, rather than quietly hoping the next poll finds it.
|
|
1148
1639
|
*/
|
|
1149
1640
|
async function startRearmed(client, tools, machineId, row) {
|
|
1150
1641
|
const level = row.run_level === 1 ? 1 : row.run_level === 2 ? 2 : 3;
|
|
@@ -1153,18 +1644,16 @@ async function startRearmed(client, tools, machineId, row) {
|
|
|
1153
1644
|
await giveUp(client, row.run_id, why);
|
|
1154
1645
|
throw new Error(why);
|
|
1155
1646
|
}
|
|
1156
|
-
let
|
|
1647
|
+
let where;
|
|
1157
1648
|
try {
|
|
1158
|
-
|
|
1159
|
-
? scratchDir()
|
|
1160
|
-
: checkoutForCodebase(await codebaseOfRun(client, row.run_id), hostname());
|
|
1649
|
+
where = await workingDirectory(client, row.run_id, level, false);
|
|
1161
1650
|
}
|
|
1162
1651
|
catch (error) {
|
|
1163
1652
|
/* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
|
|
1164
1653
|
═══ Constraint 6. `workingCopy()` is already careful about this and says
|
|
1165
1654
|
so; `scratchDir()` is not — it is `mkdirSync`, whose EACCES and ENOTDIR
|
|
1166
1655
|
messages name the directory they failed on — and `failed_because` is a
|
|
1167
|
-
column `
|
|
1656
|
+
column `cs show` prints. So the machine's own error is kept for stderr
|
|
1168
1657
|
and the record is told only what is true and shareable. */
|
|
1169
1658
|
const said = error instanceof Error ? error.message : String(error);
|
|
1170
1659
|
const why = level === 1
|
|
@@ -1180,6 +1669,17 @@ async function startRearmed(client, tools, machineId, row) {
|
|
|
1180
1669
|
POSSIBLE. Level 3 has no `dispatch`, so it has no children to have: null
|
|
1181
1670
|
says that, where an empty list would say it chose to send nobody. */
|
|
1182
1671
|
const children = level === 3 ? null : await childrenOf(client, row.run_id);
|
|
1672
|
+
const deliveredArtifact = row.ask_id !== null && row.mine
|
|
1673
|
+
? await rearmedArtifactAnswer(client, row.ask_id)
|
|
1674
|
+
: null;
|
|
1675
|
+
/* ═══ THE MERGE HAPPENS HERE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT
|
|
1676
|
+
WILL SPEAK ABOUT IT IS STARTED. ═══ The re-arm is the last thing that runs
|
|
1677
|
+
before the spawn, which is why it is the only place the outcome can reach
|
|
1678
|
+
the prompt. See `landCardWork`. */
|
|
1679
|
+
const offered = row.ask_id !== null && row.mine
|
|
1680
|
+
? await landingOffer(client, row.ask_id)
|
|
1681
|
+
: null;
|
|
1682
|
+
const landing = offered === null ? null : landCardWork(where, offered);
|
|
1183
1683
|
/* ═══ THREE REASONS, AND THE ROW SAYS WHICH. ═══ No question is ux.md's third
|
|
1184
1684
|
re-arm: everybody it sent has finished, and it is started to read them back.
|
|
1185
1685
|
`children` cannot be null on that path — only a run with children is ever
|
|
@@ -1188,9 +1688,21 @@ async function startRearmed(client, tools, machineId, row) {
|
|
|
1188
1688
|
const prompt = row.ask_id === null
|
|
1189
1689
|
? readBackPrompt(row.run_brief, row.run_report, children ?? [])
|
|
1190
1690
|
: row.mine
|
|
1191
|
-
? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '')
|
|
1691
|
+
? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '', deliveredArtifact, landing)
|
|
1192
1692
|
: escalationPrompt(row.run_brief, row.run_report, children, row.ask_id, row.question ?? '');
|
|
1193
|
-
|
|
1693
|
+
/* CURRENT AT THIS ACTIVATION, exactly as on the resume path, and ended the
|
|
1694
|
+
same way: the re-arm's claim has already happened, so a failure here ends
|
|
1695
|
+
the run with its reason rather than leaving it claimed with no process. */
|
|
1696
|
+
let rules;
|
|
1697
|
+
try {
|
|
1698
|
+
rules = await standingRulesFor(client, row.run_id);
|
|
1699
|
+
}
|
|
1700
|
+
catch (error) {
|
|
1701
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
1702
|
+
await endRun(client, level, row.run_id, row.run_card_id, why);
|
|
1703
|
+
throw new Error(`NO AGENT IS RUNNING: ${why}`);
|
|
1704
|
+
}
|
|
1705
|
+
const started = startAgent(withStandingRules(rules, prompt, where.block), level, tools.urlFor(row.run_id), where.cwd);
|
|
1194
1706
|
if (started.pid === null) {
|
|
1195
1707
|
const answer = await started.answered;
|
|
1196
1708
|
const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
|
|
@@ -1263,6 +1775,374 @@ async function childrenOf(client, runId) {
|
|
|
1263
1775
|
: c.report.split('\n').map((l) => ` ${l}`)),
|
|
1264
1776
|
]);
|
|
1265
1777
|
}
|
|
1778
|
+
/** Private owner context: enough to continue or stop work, without hierarchy or activity text. */
|
|
1779
|
+
async function ownerChildren(client, runId) {
|
|
1780
|
+
const children = await returned(client.from('panel3_runs').select('id, state, report, failed_because')
|
|
1781
|
+
.eq('parent_run_id', runId).order('started_at'), 'read', `what owner ${runId} sent others to do`);
|
|
1782
|
+
return children.map((child) => [
|
|
1783
|
+
`${child.id} ${child.state}`,
|
|
1784
|
+
...(child.failed_because ? [`ended: ${child.failed_because}`] : []),
|
|
1785
|
+
child.report ?? 'no report',
|
|
1786
|
+
].join('\n'));
|
|
1787
|
+
}
|
|
1788
|
+
async function ownerConversation(client, cardId, leased, initialLeaseRunId) {
|
|
1789
|
+
const [turns, rawAsks] = await Promise.all([
|
|
1790
|
+
returned(client.from('panel3_turns').select('id, role, body, created_at, lease_id')
|
|
1791
|
+
.eq('card_id', cardId).order('created_at'), 'read', `the visible conversation on card ${cardId}`),
|
|
1792
|
+
returned(client.from('panel3_asks').select(`id, created_at, ${ASK_CONTENT_COLUMNS}`)
|
|
1793
|
+
.eq('card_id', cardId).not('decision_id', 'is', null).order('created_at'), 'read', `the visible questions on card ${cardId}`),
|
|
1794
|
+
]);
|
|
1795
|
+
const asks = await withAskContent(client, rawAsks);
|
|
1796
|
+
const events = [
|
|
1797
|
+
...turns.map((turn) => ({
|
|
1798
|
+
at: turn.created_at,
|
|
1799
|
+
kind: turn.role,
|
|
1800
|
+
id: turn.id,
|
|
1801
|
+
body: turn.body,
|
|
1802
|
+
needsReply: turn.role === 'user'
|
|
1803
|
+
&& (leased.has(turn.id) || turn.lease_id === initialLeaseRunId),
|
|
1804
|
+
})),
|
|
1805
|
+
...asks.map((ask) => ({
|
|
1806
|
+
at: ask.created_at,
|
|
1807
|
+
kind: 'question',
|
|
1808
|
+
id: ask.id,
|
|
1809
|
+
body: ask.question ?? '(question unavailable)',
|
|
1810
|
+
answer: ask.answer,
|
|
1811
|
+
relatedArtifactId: ask.related_artifact_id,
|
|
1812
|
+
relatedArtifactRevision: ask.related_artifact_revision,
|
|
1813
|
+
artifactAnswer: artifactAnswer(ask.related_artifact_id, ask.related_artifact_revision, ask.selected_options)?.answer ?? null,
|
|
1814
|
+
})),
|
|
1815
|
+
];
|
|
1816
|
+
return events.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
|
1817
|
+
}
|
|
1818
|
+
/** The first owner gets the same durable context as every later activation. */
|
|
1819
|
+
export async function initialOwnerPrompt(client, cardId, launcherRunId, brief) {
|
|
1820
|
+
const events = await ownerConversation(client, cardId, new Set(), launcherRunId);
|
|
1821
|
+
return ownerActivationPrompt(brief, null, events, [], null);
|
|
1822
|
+
}
|
|
1823
|
+
/** Only facts delivered by this activation enter an already-resumed native
|
|
1824
|
+
* conversation. Older visible turns are already in that conversation. */
|
|
1825
|
+
export function ownerContinuationPrompt(brief, report, events, children, delivered, landing = null) {
|
|
1826
|
+
const messages = events.filter((event) => event.kind === 'user' && event.needsReply);
|
|
1827
|
+
return [
|
|
1828
|
+
'CONTINUE THE CTRL+SPC CONVERSATION YOU ALREADY OWN.',
|
|
1829
|
+
'Use only the new facts below as the new turn. Keep coordinating the whole card and speak as CTRL+SPC.',
|
|
1830
|
+
'',
|
|
1831
|
+
'NEW USER MESSAGES',
|
|
1832
|
+
...(messages.length === 0
|
|
1833
|
+
? ['None.']
|
|
1834
|
+
: messages.flatMap((event) => [`Turn ${event.id}`, event.body])),
|
|
1835
|
+
...(delivered === null ? [] : delivered.mine ? [
|
|
1836
|
+
'',
|
|
1837
|
+
'ANSWER TO YOUR QUESTION',
|
|
1838
|
+
`Question ${delivered.id}: ${delivered.question}`,
|
|
1839
|
+
`Answer: ${delivered.answer ?? '(no answer text)'}`,
|
|
1840
|
+
...presentedArtifactAnswerContext(delivered.artifactAnswer),
|
|
1841
|
+
...landingOutcomeContext(landing),
|
|
1842
|
+
] : [
|
|
1843
|
+
'',
|
|
1844
|
+
'A WORKER NEEDS YOUR DECISION',
|
|
1845
|
+
`Question ${delivered.id}: ${delivered.question}`,
|
|
1846
|
+
'Answer it with answer_escalation if you can. If the person must decide, ask them directly.',
|
|
1847
|
+
]),
|
|
1848
|
+
'',
|
|
1849
|
+
'CURRENT PRIVATE WORKER SNAPSHOT — THIS REPLACES THE PREVIOUS SNAPSHOT',
|
|
1850
|
+
...(children.length === 0 ? ['You have sent nobody.'] : children),
|
|
1851
|
+
'',
|
|
1852
|
+
'YOUR CURRENT DURABLE REPORT',
|
|
1853
|
+
report ?? 'Nothing yet.',
|
|
1854
|
+
'',
|
|
1855
|
+
'YOUR IMMUTABLE RESPONSIBILITY',
|
|
1856
|
+
brief,
|
|
1857
|
+
].join('\n');
|
|
1858
|
+
}
|
|
1859
|
+
async function ownerCandidate(client, runId) {
|
|
1860
|
+
const candidates = await returned(client.from('panel3_runs')
|
|
1861
|
+
.select('id, card_id, codebase_id, machine_id, harness, state, ended_at, attempts, pid, started_at, resumed_at, process_token, handed_back_at, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id, state)')
|
|
1862
|
+
.eq('id', runId), 'read', `conversation owner ${runId}`);
|
|
1863
|
+
const candidate = candidates[0];
|
|
1864
|
+
return candidate?.card?.conversation_run_id === candidate?.id ? candidate : null;
|
|
1865
|
+
}
|
|
1866
|
+
async function ownerDirectory(client, candidate) {
|
|
1867
|
+
return workingDirectory(client, candidate.id, 2, true, candidate.codebase_id === null ? null : undefined);
|
|
1868
|
+
}
|
|
1869
|
+
export function resumableOwnerSessionId(candidate, machineId, machineHarness) {
|
|
1870
|
+
const local = readOwnerSession(candidate.id);
|
|
1871
|
+
return candidate.machine_id === machineId
|
|
1872
|
+
&& candidate.harness === machineHarness
|
|
1873
|
+
&& local?.state === 'established'
|
|
1874
|
+
&& local.harness === machineHarness
|
|
1875
|
+
&& local.processToken === candidate.process_token
|
|
1876
|
+
? local.nativeSessionId
|
|
1877
|
+
: undefined;
|
|
1878
|
+
}
|
|
1879
|
+
async function activateOwner(client, tools, machineId, runId, afterProcessToken = null, afterPid = null) {
|
|
1880
|
+
const candidate = await ownerCandidate(client, runId);
|
|
1881
|
+
if (!candidate)
|
|
1882
|
+
return null;
|
|
1883
|
+
const machineHarness = harness();
|
|
1884
|
+
const resumeSessionId = resumableOwnerSessionId(candidate, machineId, machineHarness);
|
|
1885
|
+
/* ═══ THE EXISTENCE CHECK BEFORE THE CLAIM, AND THE COPY AFTER IT. ═══ This
|
|
1886
|
+
was the whole resolution, which was right while resolving meant reading a
|
|
1887
|
+
folder out of a file. Since worktrees-8 it also CREATES one, and a daemon
|
|
1888
|
+
that lost the activation race would leave a branch and a folder behind for
|
|
1889
|
+
an owner it never activated. The refusal this position exists for — a
|
|
1890
|
+
machine that does not have this codebase must not take the activation —
|
|
1891
|
+
is unchanged, because it is the located checkout that answers it. */
|
|
1892
|
+
if (candidate.codebase_id !== null) {
|
|
1893
|
+
checkoutForCodebase(await codebaseOfRun(client, candidate.id), hostname());
|
|
1894
|
+
}
|
|
1895
|
+
const { data, error } = await client.rpc('panel3_take_owner_activation', {
|
|
1896
|
+
p_run_id: runId,
|
|
1897
|
+
p_machine_id: machineId,
|
|
1898
|
+
p_agent: machineHarness,
|
|
1899
|
+
p_after_process_token: afterProcessToken,
|
|
1900
|
+
p_after_pid: afterPid,
|
|
1901
|
+
});
|
|
1902
|
+
if (error)
|
|
1903
|
+
throw new Error(`could not activate conversation owner ${runId}: ${error.message}`);
|
|
1904
|
+
const claimed = data?.[0];
|
|
1905
|
+
if (!claimed)
|
|
1906
|
+
return null;
|
|
1907
|
+
const [events, children] = await Promise.all([
|
|
1908
|
+
ownerConversation(client, claimed.run_card_id, new Set(claimed.turn_ids ?? [])),
|
|
1909
|
+
ownerChildren(client, runId),
|
|
1910
|
+
]);
|
|
1911
|
+
const currentArtifactAnswer = deliveredArtifactAnswer(events, claimed);
|
|
1912
|
+
const delivered = claimed.ask_id === null ? null : {
|
|
1913
|
+
id: claimed.ask_id,
|
|
1914
|
+
question: claimed.question ?? '(question unavailable)',
|
|
1915
|
+
answer: claimed.answer,
|
|
1916
|
+
mine: claimed.mine === true,
|
|
1917
|
+
artifactAnswer: currentArtifactAnswer,
|
|
1918
|
+
};
|
|
1919
|
+
/* ═══ AFTER THE CLAIM, SO THE ENDING IS THE ONE THIS PATH HAS. ═══ Every
|
|
1920
|
+
failure below the claim ends the activation with `giveUp` and its process
|
|
1921
|
+
token; a throw above it would merely be an activation that did not happen.
|
|
1922
|
+
|
|
1923
|
+
═══ AND IT MATTERS MOST HERE. ═══ This is the owner, which lives for the
|
|
1924
|
+
whole card and whose native session is RESUMED, so it is the one agent that
|
|
1925
|
+
can be running while a person edits or deletes a rule. Reading at every
|
|
1926
|
+
activation is what makes an edit govern the rest of the conversation, and
|
|
1927
|
+
the block's own supersession sentence is what makes a DELETION take effect
|
|
1928
|
+
in a session that still holds the older copy.
|
|
1929
|
+
|
|
1930
|
+
═══ AND IT IS BEFORE THE PROMPT SINCE worktrees-8 C1, because the prompt now
|
|
1931
|
+
says what the product DID with the person's answer, and the landing needs
|
|
1932
|
+
the card's copy. Nothing in the prompt depended on it before. */
|
|
1933
|
+
let rules;
|
|
1934
|
+
/* THE CARD'S COPY IS MADE IN THE SAME WINDOW AND UNDER THE SAME ENDING, for
|
|
1935
|
+
the reason the check above gives: it writes, so it happens after the claim,
|
|
1936
|
+
and a failure to make it is an activation that ends rather than one that
|
|
1937
|
+
sits `running` with no process. */
|
|
1938
|
+
let where;
|
|
1939
|
+
/* ═══ AND WHAT IS ATTACHED, READ IN THE SAME WINDOW AND UNDER THE SAME
|
|
1940
|
+
ENDING. ═══ attaching-after-the-fact-10: a person may attach to a card that
|
|
1941
|
+
is already running, and the owner's brief is immutable, so the only account
|
|
1942
|
+
of attachments it would otherwise get is the one frozen at dispatch. Read
|
|
1943
|
+
at every activation, exactly as the rules are, and for the same reason a
|
|
1944
|
+
failed read ends the activation rather than continuing: "nothing is
|
|
1945
|
+
attached" is a different card from the one the person sent.
|
|
1946
|
+
|
|
1947
|
+
THE CODEBASE LINES ARE DROPPED. `whatWasAttached` partitions those into a
|
|
1948
|
+
section whose own text says "its one codebase is named separately below",
|
|
1949
|
+
a forward reference to something `workBrief` supplies and an owner
|
|
1950
|
+
activation does not. This block carries the PERSON's attachments; where the
|
|
1951
|
+
owner is working is `where.block`'s answer. */
|
|
1952
|
+
let attached;
|
|
1953
|
+
try {
|
|
1954
|
+
where = await ownerDirectory(client, candidate);
|
|
1955
|
+
rules = await standingRulesFor(client, runId);
|
|
1956
|
+
attached = whatWasAttached((await attachmentsFor(client, claimed.run_card_id))
|
|
1957
|
+
.filter((line) => !line.startsWith('codebase ')));
|
|
1958
|
+
}
|
|
1959
|
+
catch (error) {
|
|
1960
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
1961
|
+
await giveUp(client, runId, why, claimed.process_token);
|
|
1962
|
+
throw new Error(`NO AGENT IS RUNNING: ${why}`);
|
|
1963
|
+
}
|
|
1964
|
+
/* THE MERGE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT WILL SPEAK ABOUT
|
|
1965
|
+
IT IS STARTED. The owner reaches an answer by this route as often as by the
|
|
1966
|
+
re-arm, which is why the artifact answer is read on both and this is too. */
|
|
1967
|
+
const offered = claimed.ask_id !== null && claimed.mine === true
|
|
1968
|
+
? await landingOffer(client, claimed.ask_id)
|
|
1969
|
+
: null;
|
|
1970
|
+
const landing = offered === null ? null : landCardWork(where, offered);
|
|
1971
|
+
const prompt = resumeSessionId
|
|
1972
|
+
? ownerContinuationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered, landing)
|
|
1973
|
+
: ownerActivationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered !== null && !delivered.mine
|
|
1974
|
+
? { id: delivered.id, question: delivered.question }
|
|
1975
|
+
: null, currentArtifactAnswer,
|
|
1976
|
+
/* ═══ A PROCESS OF ITS OWN ENDED BEFORE IT FINISHED. ═══ `afterPid` is the
|
|
1977
|
+
fact, and it is non-null on all three paths that follow one: a harness
|
|
1978
|
+
that crashed, a machine that went down, and now a person's correction.
|
|
1979
|
+
The sentences it adds say what to do and never why, because those three
|
|
1980
|
+
are not the same event and `prompt.ts` exists to stop an agent being
|
|
1981
|
+
told an untrue reason for its own restart. */
|
|
1982
|
+
afterPid !== null, landing);
|
|
1983
|
+
const started = startAgent(withStandingRules(rules, prompt, where.block, attached), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) });
|
|
1984
|
+
if (started.pid === null) {
|
|
1985
|
+
const answer = await started.answered;
|
|
1986
|
+
const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
|
|
1987
|
+
await giveUp(client, runId, reason, claimed.process_token);
|
|
1988
|
+
throw new Error(`NO AGENT IS RUNNING: ${reason}`);
|
|
1989
|
+
}
|
|
1990
|
+
try {
|
|
1991
|
+
await recordProcess(client, runId, started.pid, undefined, claimed.process_token);
|
|
1992
|
+
}
|
|
1993
|
+
catch (error) {
|
|
1994
|
+
said(`${error instanceof Error ? error.message : String(error)} (the run is still going)`);
|
|
1995
|
+
}
|
|
1996
|
+
return {
|
|
1997
|
+
settled: settle(client, tools, machineId, 2, runId, claimed.run_card_id, started, true, claimed.process_token, ownerSessionLifecycle(started, runId, machineHarness, claimed.process_token, resumeSessionId)),
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
/** The only owner rows the normal poll may try, including an explicit hand-back move. */
|
|
2001
|
+
export function ownerPollClaim(candidate) {
|
|
2002
|
+
if (candidate.state === 'running') {
|
|
2003
|
+
if (candidate.handed_back_at === null || candidate.process_token === null)
|
|
2004
|
+
return null;
|
|
2005
|
+
return { afterProcessToken: candidate.process_token, afterPid: null };
|
|
2006
|
+
}
|
|
2007
|
+
if (candidate.state === 'finished' || candidate.state === 'asked') {
|
|
2008
|
+
return { afterProcessToken: null, afterPid: null };
|
|
2009
|
+
}
|
|
2010
|
+
return null;
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* ═══ THE PROCESS A PERSON'S CORRECTION CANNOT HAVE REACHED. ═══
|
|
2014
|
+
*
|
|
2015
|
+
* redirect-5/ux.md: stop ends work, and changing its direction mid-flight did
|
|
2016
|
+
* not exist. ux.md's mechanism is "redirect is stop plus respawn", and the
|
|
2017
|
+
* respawn half is already built: a process that dies is started again as itself
|
|
2018
|
+
* by `settle`, and the claim it goes through leases every unaddressed person
|
|
2019
|
+
* turn on its way. **So the only thing missing was the ending**, and this is it.
|
|
2020
|
+
*
|
|
2021
|
+
* ═══ IT IS A MIRROR OF THE RETRY CLAIM, AND MUST BE CHANGED WITH IT. ═══
|
|
2022
|
+
* `20260914090000`'s retry branch requires the token, `running`, `ended_at` null,
|
|
2023
|
+
* the pid, and `attempts < 3`. The kill is the ONE irreversible act in this file:
|
|
2024
|
+
* every other predicate here pre-filters an RPC that can refuse harmlessly, and
|
|
2025
|
+
* a claim refused AFTER a kill falls through `settle` to `giveUp`, which turns a
|
|
2026
|
+
* person's correction into a FAILED CARD. So every condition below is either
|
|
2027
|
+
* that mirror, or a product rule with a named cost.
|
|
2028
|
+
*
|
|
2029
|
+
* Two things are NOT here, because neither is a property of the row: whether
|
|
2030
|
+
* this run owns its card, and whether this daemon holds its process. Both are
|
|
2031
|
+
* the caller's, and `mine` is what makes the respawn possible at all.
|
|
2032
|
+
*/
|
|
2033
|
+
export function redirectedProcess(candidate, machineId,
|
|
2034
|
+
/** The newest unaddressed person turn per card. MAX, never first-seen: a turn
|
|
2035
|
+
* left unaddressed while a question was open would otherwise pin its card
|
|
2036
|
+
* below `resumed_at` for good and nothing on it could ever redirect. */
|
|
2037
|
+
waiting, cardId, booted) {
|
|
2038
|
+
// The claim's own five, mirrored.
|
|
2039
|
+
if (candidate.state !== 'running' || candidate.ended_at !== null)
|
|
2040
|
+
return null;
|
|
2041
|
+
if (candidate.attempts >= MAX_ATTEMPTS)
|
|
2042
|
+
return null;
|
|
2043
|
+
// The person moved this card to another machine. Killing here makes `settle`
|
|
2044
|
+
// retry locally and the claim clears `handed_back_at`, silently reversing them.
|
|
2045
|
+
if (candidate.handed_back_at !== null)
|
|
2046
|
+
return null;
|
|
2047
|
+
// `mine` is not enough: a takeover moves the row while this daemon still holds
|
|
2048
|
+
// a promise for it, and the claim then refuses on the machine predicate.
|
|
2049
|
+
if (candidate.machine_id !== machineId)
|
|
2050
|
+
return null;
|
|
2051
|
+
/* ═══ A CARD THAT NEEDS THE PERSON IS NOT A CARD THEY ARE REDIRECTING. ═══
|
|
2052
|
+
The claim refuses to lease turns while a person's question is open, so a
|
|
2053
|
+
kill here would destroy the work and NOT deliver the correction. The card's
|
|
2054
|
+
state is written from a character-identical predicate by the
|
|
2055
|
+
`panel3_prepare_user_turn` trigger on every person message, so it is the
|
|
2056
|
+
same fact, one read earlier. */
|
|
2057
|
+
if (candidate.card_state !== 'working')
|
|
2058
|
+
return null;
|
|
2059
|
+
/* ═══ AND IT HAS TO BE A MESSAGE THIS PROCESS CANNOT HAVE SEEN. ═══ The rule
|
|
2060
|
+
`panel3_take_turns` already applies one level up. Measured against the
|
|
2061
|
+
attempt that is running NOW, which is also what stops a replacement being
|
|
2062
|
+
killed by the very message that caused it. */
|
|
2063
|
+
const said = waiting.get(cardId);
|
|
2064
|
+
if (said === undefined)
|
|
2065
|
+
return null;
|
|
2066
|
+
if (new Date(said).getTime() <= new Date(candidate.resumed_at ?? candidate.started_at).getTime()) {
|
|
2067
|
+
return null;
|
|
2068
|
+
}
|
|
2069
|
+
return runProcessIsAlive(candidate, booted) ? candidate.pid : null;
|
|
2070
|
+
}
|
|
2071
|
+
export function pendingOwnerSessionWithinGrace(candidate, now = Date.now()) {
|
|
2072
|
+
const local = readOwnerSession(candidate.id);
|
|
2073
|
+
return !!local
|
|
2074
|
+
&& local.state === 'pending'
|
|
2075
|
+
&& local.harness === candidate.harness
|
|
2076
|
+
&& local.processToken === candidate.process_token
|
|
2077
|
+
&& now - new Date(local.updatedAt).getTime() < OWNER_SESSION_GRACE_MS;
|
|
2078
|
+
}
|
|
2079
|
+
async function takeOwnerActivations(client, tools, machineId, mine, hold) {
|
|
2080
|
+
/* ═══ THE MESSAGES NOBODY HAS TAKEN, READ BEFORE THE RUNS, DELIBERATELY. ═══
|
|
2081
|
+
A claim landing between these two reads stamps a `resumed_at` NEWER than
|
|
2082
|
+
every turn it just leased, so the redirect below refuses and nothing is
|
|
2083
|
+
killed. In the other order the same claim leaves a stale `resumed_at` beside
|
|
2084
|
+
a turn that now reads unaddressed, and the daemon kills the replacement it
|
|
2085
|
+
has just started. One narrow indexed read (`panel3_turns_takeable_idx`),
|
|
2086
|
+
unconditional because that ordering is the whole point of it. */
|
|
2087
|
+
const outstanding = await returned(client.from('panel3_turns')
|
|
2088
|
+
.select('card_id, created_at')
|
|
2089
|
+
.eq('role', 'user')
|
|
2090
|
+
.is('addressed_at', null), 'read', 'the messages nobody has taken yet');
|
|
2091
|
+
const waiting = new Map();
|
|
2092
|
+
for (const turn of outstanding) {
|
|
2093
|
+
// THE NEWEST PER CARD. See `redirectedProcess`: first-wins would pin a card
|
|
2094
|
+
// below its own `resumed_at` for good.
|
|
2095
|
+
const held = waiting.get(turn.card_id);
|
|
2096
|
+
if (held === undefined || turn.created_at > held)
|
|
2097
|
+
waiting.set(turn.card_id, turn.created_at);
|
|
2098
|
+
}
|
|
2099
|
+
const candidates = await returned(client.from('panel3_runs')
|
|
2100
|
+
.select('id, card_id, codebase_id, machine_id, harness, state, ended_at, attempts, pid, started_at, resumed_at, process_token, handed_back_at, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id, state)')
|
|
2101
|
+
.in('state', ['finished', 'asked', 'running']), 'read', 'conversation owners that may be ready');
|
|
2102
|
+
for (const candidate of candidates) {
|
|
2103
|
+
if (candidate.card?.conversation_run_id !== candidate.id)
|
|
2104
|
+
continue;
|
|
2105
|
+
/* ═══ A RUN THIS DAEMON IS HOLDING IS THE ONLY ONE A CORRECTION CAN
|
|
2106
|
+
REDIRECT. ═══ Not because of the claim, which is not made here at all, but
|
|
2107
|
+
because of what happens AFTER the kill: the respawn is the dying process's
|
|
2108
|
+
own `settle`, and only the daemon that started it has one. Kill anything
|
|
2109
|
+
else and there is a dead process, a row still reading `running` and a card
|
|
2110
|
+
still saying `working`, with nothing local to retry it. */
|
|
2111
|
+
if (mine.has(candidate.id)) {
|
|
2112
|
+
const pid = redirectedProcess({ ...candidate, card_state: candidate.card?.state ?? null }, machineId, waiting, candidate.card_id, bootedAt());
|
|
2113
|
+
if (pid !== null) {
|
|
2114
|
+
killTree({ pid, kill: (signal) => process.kill(pid, signal) });
|
|
2115
|
+
/* ═══ SIGNALLED, NOT KILLED, AND THE WORD IS THE POINT. ═══ `killTree`
|
|
2116
|
+
swallows a refused signal on both platforms, so saying "killed" would
|
|
2117
|
+
claim a death this daemon never observed. A process that survives is
|
|
2118
|
+
still running, still held, and its turn is still unaddressed and still
|
|
2119
|
+
newer than `resumed_at`, so the next poll signals it again — the same
|
|
2120
|
+
rule `killStopped` states two hundred lines below. */
|
|
2121
|
+
out(`redirect card ${candidate.card_id} run ${candidate.id} signalled pid ${pid}`);
|
|
2122
|
+
}
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
if (pendingOwnerSessionWithinGrace(candidate))
|
|
2126
|
+
continue;
|
|
2127
|
+
if (candidate.handed_back_at !== null
|
|
2128
|
+
&& candidate.machine_id === machineId
|
|
2129
|
+
&& (runProcessIsAlive(candidate, bootedAt())
|
|
2130
|
+
|| (candidate.pid === null
|
|
2131
|
+
&& Date.now() - new Date(candidate.resumed_at ?? candidate.started_at).getTime() < PID_GRACE_MS)))
|
|
2132
|
+
continue;
|
|
2133
|
+
const claim = ownerPollClaim(candidate);
|
|
2134
|
+
if (!claim)
|
|
2135
|
+
continue;
|
|
2136
|
+
try {
|
|
2137
|
+
const activated = await activateOwner(client, tools, machineId, candidate.id, claim.afterProcessToken, claim.afterPid);
|
|
2138
|
+
if (activated)
|
|
2139
|
+
hold(candidate.id, activated.settled);
|
|
2140
|
+
}
|
|
2141
|
+
catch (error) {
|
|
2142
|
+
said(`could not activate owner ${candidate.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
1266
2146
|
// ---------------------------------------------------------------------------
|
|
1267
2147
|
/**
|
|
1268
2148
|
* ═══ THE TWO ENDS THE PERSON CAUSES, WHICH ARE THE TWO THIS DAEMON KILLS. ═══
|
|
@@ -1300,6 +2180,46 @@ function runProcessIsAlive(run, booted) {
|
|
|
1300
2180
|
return false;
|
|
1301
2181
|
return processIsAlive(run.pid);
|
|
1302
2182
|
}
|
|
2183
|
+
/** Reconcile only CTRL+SPC-owned local session state. Native Claude and Windows
|
|
2184
|
+
* Codex transcripts live in harness-owned locations and are deliberately not
|
|
2185
|
+
* touched here. A current owner row, live PID, in-flight process, or fresh
|
|
2186
|
+
* pid-null claim always defers cleanup. Stable state is removed only after the
|
|
2187
|
+
* owner row disappears, becomes terminal, or no longer owns its card. */
|
|
2188
|
+
export async function reconcileOwnerSessions(client, machineId, _machineHarness, inFlightOwnerIds = new Set()) {
|
|
2189
|
+
const mappingIds = listOwnerSessionIds();
|
|
2190
|
+
const homeIds = listPanel3CodexOwnerHomeIds();
|
|
2191
|
+
const all = [...new Set([...mappingIds, ...homeIds])];
|
|
2192
|
+
const invalid = all.filter((id) => !validSessionUuid(id));
|
|
2193
|
+
for (const id of invalid)
|
|
2194
|
+
removePanel3CodexOwnerHome(id);
|
|
2195
|
+
const ids = all.filter(validSessionUuid);
|
|
2196
|
+
if (ids.length === 0)
|
|
2197
|
+
return;
|
|
2198
|
+
const rows = await returned(client.from('panel3_runs')
|
|
2199
|
+
.select('id, machine_id, harness, state, pid, started_at, resumed_at, ended_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
|
|
2200
|
+
.in('id', ids), 'read', 'which local conversation sessions still have an owner');
|
|
2201
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
2202
|
+
for (const id of ids) {
|
|
2203
|
+
if (inFlightOwnerIds.has(id))
|
|
2204
|
+
continue;
|
|
2205
|
+
const row = byId.get(id);
|
|
2206
|
+
const localProcessInUse = !!row
|
|
2207
|
+
&& row.machine_id === machineId
|
|
2208
|
+
&& ((row.pid !== null && processIsAlive(row.pid))
|
|
2209
|
+
|| (row.state === 'running'
|
|
2210
|
+
&& row.ended_at === null
|
|
2211
|
+
&& row.pid === null
|
|
2212
|
+
&& Date.now() - new Date(row.resumed_at ?? row.started_at).getTime() < PID_GRACE_MS));
|
|
2213
|
+
const rowStillOwnsConversation = !!row
|
|
2214
|
+
&& row.card?.conversation_run_id === id
|
|
2215
|
+
&& ['running', 'asked', 'finished'].includes(row.state);
|
|
2216
|
+
if (localProcessInUse || rowStillOwnsConversation)
|
|
2217
|
+
continue;
|
|
2218
|
+
removeOwnerSession(id);
|
|
2219
|
+
if (homeIds.includes(id))
|
|
2220
|
+
removePanel3CodexOwnerHome(id);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
1303
2223
|
/**
|
|
1304
2224
|
* ═══ THE USER STOPPED IT, OR THEIR NEXT MESSAGE REPLACED IT, SO THE PROCESS
|
|
1305
2225
|
* STOPS. ═══
|
|
@@ -1441,7 +2361,7 @@ async function killStopped(client, machineId) {
|
|
|
1441
2361
|
async function recoverStranded(client, tools, machineId, mine, hold) {
|
|
1442
2362
|
const live = await returned(client
|
|
1443
2363
|
.from('panel3_runs')
|
|
1444
|
-
.select('id, card_id, parent_run_id, state, pid, started_at, resumed_at')
|
|
2364
|
+
.select('id, card_id, parent_run_id, state, pid, started_at, resumed_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
|
|
1445
2365
|
.eq('machine_id', machineId)
|
|
1446
2366
|
/* ═══ AND A RUN THAT STOPPED TO ASK IS SWEPT TOO, WHICH IS WHAT MAKES THE
|
|
1447
2367
|
RE-ARM'S OWN PREDICATE SAFE. ═══ `panel3_take_rearms` will only start a run
|
|
@@ -1468,6 +2388,23 @@ async function recoverStranded(client, tools, machineId, mine, hold) {
|
|
|
1468
2388
|
continue;
|
|
1469
2389
|
if (runProcessIsAlive(run, booted))
|
|
1470
2390
|
continue;
|
|
2391
|
+
const isOwner = run.card?.conversation_run_id === run.id && run.process_token !== null;
|
|
2392
|
+
if (isOwner) {
|
|
2393
|
+
try {
|
|
2394
|
+
if (run.state === 'asked') {
|
|
2395
|
+
await writeAnswer(client, run.id, run.card_id, null, run.process_token ?? undefined);
|
|
2396
|
+
}
|
|
2397
|
+
else {
|
|
2398
|
+
const resumed = await activateOwner(client, tools, machineId, run.id, run.process_token, run.pid);
|
|
2399
|
+
if (resumed)
|
|
2400
|
+
hold(run.id, resumed.settled);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
catch (error) {
|
|
2404
|
+
said(`could not recover owner ${run.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2405
|
+
}
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
1471
2408
|
if (run.state === 'asked') {
|
|
1472
2409
|
/* ═══ ITS END IS STAMPED AND ITS TURNS ARE LEFT ALONE. ═══ This run is not
|
|
1473
2410
|
handed back and is not started again from here: its continuation is the
|
|
@@ -1662,7 +2599,7 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
|
|
|
1662
2599
|
whether its pid means anything here. See the header. */
|
|
1663
2600
|
const offered = await returned(client
|
|
1664
2601
|
.from('panel3_runs')
|
|
1665
|
-
.select('id, card_id, machine_id, pid, started_at, resumed_at')
|
|
2602
|
+
.select('id, card_id, machine_id, pid, started_at, resumed_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
|
|
1666
2603
|
.not('handed_back_at', 'is', null)
|
|
1667
2604
|
/* WHAT THE OFFER MEANS, RESTATED IN THE READ. `panel3_hand_back` only ever
|
|
1668
2605
|
stamps a dispatched run that is still going, and `panel3_resume` refuses
|
|
@@ -1708,7 +2645,9 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
|
|
|
1708
2645
|
continue;
|
|
1709
2646
|
}
|
|
1710
2647
|
try {
|
|
1711
|
-
const resumed =
|
|
2648
|
+
const resumed = run.card?.conversation_run_id === run.id && run.process_token !== null
|
|
2649
|
+
? await activateOwner(client, tools, machineId, run.id, run.process_token, null)
|
|
2650
|
+
: await resumeRun(client, tools, machineId, run.id, null);
|
|
1712
2651
|
if (resumed)
|
|
1713
2652
|
hold(run.id, resumed.settled);
|
|
1714
2653
|
else
|
|
@@ -1729,7 +2668,7 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
|
|
|
1729
2668
|
*
|
|
1730
2669
|
* ═══ ONE LOOP, TWO CALLERS, AND THAT IS THE WHOLE OF recovery-1 SLICE 4. ═══
|
|
1731
2670
|
* `startPanel` below is `cs start`'s entry and hands in the session the
|
|
1732
|
-
* installed CLI already holds. `
|
|
2671
|
+
* installed CLI already holds. `panel3/cli.js run` is the acceptance harness's entry and
|
|
1733
2672
|
* signs in from the environment against its own isolated account. Neither is a
|
|
1734
2673
|
* copy of the other: a second poll loop would be two things leasing the same
|
|
1735
2674
|
* turns with two ideas of what recovery may reap.
|
|
@@ -1738,6 +2677,135 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
|
|
|
1738
2677
|
* already has one. Its presence is also what says THIS PROCESS IS NOT v3's, so
|
|
1739
2678
|
* the signals belong to somebody else — see the handler block below.
|
|
1740
2679
|
*/
|
|
2680
|
+
/**
|
|
2681
|
+
* ═══ THE CARD IS OVER, SO ITS COPY GOES — AND THE BRANCH STAYS. ═══
|
|
2682
|
+
*
|
|
2683
|
+
* A SWEEP ON THE DAEMON'S OWN LOOP RATHER THAN A HOOK ON A RUN ENDING, and the
|
|
2684
|
+
* reason is that there is no place the daemon learns a card ended. The `done`
|
|
2685
|
+
* transition happens inside SQL, and `settle` fires per PROCESS at every level:
|
|
2686
|
+
* a level 3 worker exiting while its level 2 owner is still editing the same
|
|
2687
|
+
* tree would take the tree out from under it. So the question is asked of the
|
|
2688
|
+
* record, of every run on the card, and a card with anything still running is
|
|
2689
|
+
* passed over.
|
|
2690
|
+
*
|
|
2691
|
+
* ═══ THE FILESYSTEM IS ASKED FIRST, SO A MACHINE WITH NO COPIES ASKS NOTHING.
|
|
2692
|
+
* ═══ And the folders it finds are what bounds the query: only the codebases
|
|
2693
|
+
* this machine actually holds a copy for.
|
|
2694
|
+
*
|
|
2695
|
+
* A folder no row explains is LEFT ALONE. Nothing here deletes something it
|
|
2696
|
+
* cannot account for.
|
|
2697
|
+
*/
|
|
2698
|
+
async function sweepFinishedWorktrees(client) {
|
|
2699
|
+
const held = worktreesOnThisMachine();
|
|
2700
|
+
if (held.length === 0)
|
|
2701
|
+
return;
|
|
2702
|
+
/* ═══ ASKED IN TWO STEPS, BECAUSE THE TWO QUESTIONS ARE NOT THE SAME ONE.
|
|
2703
|
+
═══ WHICH CARD a folder belongs to is answered by the runs that carry a
|
|
2704
|
+
branch, which are the runs in a codebase. WHETHER THAT CARD IS STILL BEING
|
|
2705
|
+
WORKED is answered by ALL of its runs, and its level 1 coordinator has no
|
|
2706
|
+
codebase at all — so a single query filtered by codebase would call a card
|
|
2707
|
+
finished while the agent coordinating it was still going.
|
|
2708
|
+
|
|
2709
|
+
Neither is filtered to this machine. The FOLDERS are this machine's, and
|
|
2710
|
+
only a run here can be inside one, but "the card is over" is a fact about
|
|
2711
|
+
the card, and a card dispatched across machines has runs elsewhere. */
|
|
2712
|
+
const stamped = await returned(client.from('panel3_runs')
|
|
2713
|
+
.select('card_id, codebase_id, branch')
|
|
2714
|
+
.in('codebase_id', [...new Set(held.map((one) => one.codebaseId))])
|
|
2715
|
+
.not('branch', 'is', null), 'read', 'which cards the copies on this machine belong to');
|
|
2716
|
+
const cardOfFolder = new Map(held.map((one) => [one.folder, [...new Set(stamped
|
|
2717
|
+
.filter((row) => (row.codebase_id === one.codebaseId && row.branch !== null && folderIsBranch(one.slug, row.branch)))
|
|
2718
|
+
.map((row) => row.card_id))]]));
|
|
2719
|
+
const cards = [...new Set([...cardOfFolder.values()].flat())];
|
|
2720
|
+
if (cards.length === 0)
|
|
2721
|
+
return;
|
|
2722
|
+
const live = new Set((await returned(client.from('panel3_runs')
|
|
2723
|
+
.select('card_id')
|
|
2724
|
+
.in('card_id', cards)
|
|
2725
|
+
.is('ended_at', null), 'read', 'which of those cards still have an agent going')).map((row) => row.card_id));
|
|
2726
|
+
const touched = new Set();
|
|
2727
|
+
for (const one of held) {
|
|
2728
|
+
const cardIds = cardOfFolder.get(one.folder) ?? [];
|
|
2729
|
+
if (cardIds.length === 0 || cardIds.some((cardId) => live.has(cardId)))
|
|
2730
|
+
continue;
|
|
2731
|
+
try {
|
|
2732
|
+
settleCardWorktree(one.folder);
|
|
2733
|
+
touched.add(one.codebaseId);
|
|
2734
|
+
out(`cleaned card copy for ${cardIds.join(', ')}`);
|
|
2735
|
+
}
|
|
2736
|
+
catch (error) {
|
|
2737
|
+
/* SAID, NOT FATAL, AND NOT RETRIED HARDER. The work is committed or it is
|
|
2738
|
+
not; either way the copy is still there and the next poll tries again.
|
|
2739
|
+
Taking the daemon's loop down over cleanup would stop every card. */
|
|
2740
|
+
said(`could not clean up a finished card's copy: ${error instanceof Error ? error.message : String(error)}`);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
/* THE BASE LOCK LAST, AND ONLY WHERE SOMETHING WAS REMOVED. It is what makes
|
|
2744
|
+
the refusal real for every other card in that codebase, so it survives
|
|
2745
|
+
until none of them is left. */
|
|
2746
|
+
for (const codebaseId of touched) {
|
|
2747
|
+
try {
|
|
2748
|
+
releaseBaseBranch(codebaseId);
|
|
2749
|
+
}
|
|
2750
|
+
catch { /* it is held, which is safe */ }
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
/** The last mismatch state this daemon said out loud, so a poll every two
|
|
2754
|
+
* seconds does not narrate the same standing fact thirty times a minute.
|
|
2755
|
+
*
|
|
2756
|
+
* ═══ SAID ONCE PER STATE, NOT ONCE PER PROCESS. ═══ The same discipline v2's
|
|
2757
|
+
* `roleKey` (`orchestrator.ts`) uses for its own designation lines, and for the
|
|
2758
|
+
* same reason: the person re-designates while the daemon is running, so
|
|
2759
|
+
* "matching again" is a genuinely different state that they must hear about
|
|
2760
|
+
* too, while the unchanged one repeated is noise that buries every useful
|
|
2761
|
+
* line. Null means nothing has been said yet. */
|
|
2762
|
+
let saidHarnessState = null;
|
|
2763
|
+
/**
|
|
2764
|
+
* ═══ THE DESIGNATION NAMES A HARNESS THIS DAEMON IS NOT, AND SOMEBODY HAS TO
|
|
2765
|
+
* SAY SO. ═══
|
|
2766
|
+
*
|
|
2767
|
+
* The take's agent filter lives in SQL and answers with zero rows, which is
|
|
2768
|
+
* right — a daemon may not act outside its designation — but zero rows is also
|
|
2769
|
+
* what "nothing is waiting" looks like, and the two are indistinguishable from
|
|
2770
|
+
* the outside. This is the one place they can be told apart, because it is the
|
|
2771
|
+
* only place that holds BOTH facts at once: the harness this process actually
|
|
2772
|
+
* runs, and the harness the person has designated.
|
|
2773
|
+
*
|
|
2774
|
+
* ═══ IT DOES NOT REFUSE, RETRY OR RE-READ THE HARNESS. ═══ `CTRL_SPC_V3_AGENT`
|
|
2775
|
+
* is fixed for the life of the process by design (`spawn.ts`: reported, never
|
|
2776
|
+
* chosen), and honouring a switch by silently spawning the other binary would
|
|
2777
|
+
* attribute a whole session of results to a harness that never ran. So the
|
|
2778
|
+
* daemon keeps polling, keeps taking whatever it legitimately may, and says the
|
|
2779
|
+
* one true sentence about why this particular work is not moving.
|
|
2780
|
+
*
|
|
2781
|
+
* ═══ AND A FAILED READ IS NOT A MISMATCH. ═══ Constraint 4, the same rule
|
|
2782
|
+
* `designatedCoordinator` states: a read that failed does not mean nobody is
|
|
2783
|
+
* designated, and it must not produce a sentence claiming to know which harness
|
|
2784
|
+
* was chosen. It throws, and the poll's own catch treats it as a poll that did
|
|
2785
|
+
* not finish — the next one is two seconds away.
|
|
2786
|
+
*/
|
|
2787
|
+
async function reportHarnessMismatch(client, machineId, machineHarness) {
|
|
2788
|
+
const designation = await designatedCoordinator(client);
|
|
2789
|
+
/* Nobody designated, or somebody else's machine: neither is this daemon
|
|
2790
|
+
failing to honour anything. `panel3_take_turns` lets ANY machine take when
|
|
2791
|
+
nothing is designated, and a designation naming another machine is that
|
|
2792
|
+
machine's business — v2's own listener already says both of those
|
|
2793
|
+
(`roleLine`'s 'none' and 'other-machine'), and repeating them here would be
|
|
2794
|
+
two voices on one fact. */
|
|
2795
|
+
const mismatched = designation !== null
|
|
2796
|
+
&& designation.machineId === machineId
|
|
2797
|
+
&& designation.agent !== machineHarness;
|
|
2798
|
+
const state = mismatched ? `mismatch:${designation.agent}` : 'ok';
|
|
2799
|
+
if (state === saidHarnessState)
|
|
2800
|
+
return;
|
|
2801
|
+
saidHarnessState = state;
|
|
2802
|
+
if (!mismatched)
|
|
2803
|
+
return;
|
|
2804
|
+
said(`${designation.agent} is designated to coordinate on this machine, and this daemon runs `
|
|
2805
|
+
+ `${machineHarness}, so it will not pick work up. Nothing will act on what you send until `
|
|
2806
|
+
+ `this daemon is restarted with CTRL_SPC_V3_AGENT=${designation.agent}, or ${machineHarness} `
|
|
2807
|
+
+ 'is designated in the app.');
|
|
2808
|
+
}
|
|
1741
2809
|
export async function run(args, injected) {
|
|
1742
2810
|
let once = false;
|
|
1743
2811
|
for (const arg of args) {
|
|
@@ -1794,8 +2862,8 @@ export async function run(args, injected) {
|
|
|
1794
2862
|
`tools` is referenced inside the callback it is being given, which is safe
|
|
1795
2863
|
for the plain reason that the callback can only run once a request has
|
|
1796
2864
|
arrived at a server that by then exists. */
|
|
1797
|
-
const tools = await startToolsServer(client, async (parentRunId, brief, codebase) => {
|
|
1798
|
-
const child = await startChild(client, tools, machineId, parentRunId, brief, codebase);
|
|
2865
|
+
const tools = await startToolsServer(client, async (parentRunId, brief, codebase, processToken) => {
|
|
2866
|
+
const child = await startChild(client, tools, machineId, parentRunId, brief, codebase, processToken);
|
|
1799
2867
|
hold(child.runId, child.settled);
|
|
1800
2868
|
return { runId: child.runId };
|
|
1801
2869
|
});
|
|
@@ -1834,7 +2902,7 @@ export async function run(args, injected) {
|
|
|
1834
2902
|
for (;;) {
|
|
1835
2903
|
/* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
|
|
1836
2904
|
here throws on a network or database error, by design (constraint 7), and
|
|
1837
|
-
until Slice 4 that threw straight out of `
|
|
2905
|
+
until Slice 4 that threw straight out of `panel3/cli.js run` and exited the process.
|
|
1838
2906
|
Inside `cs start` that is no longer an honest outcome twice over: it would
|
|
1839
2907
|
take the v2 presence heartbeat down with it, and it would leave a stranded
|
|
1840
2908
|
card printing `cs start` at a person who IS running `cs start`, which is
|
|
@@ -1856,7 +2924,13 @@ export async function run(args, injected) {
|
|
|
1856
2924
|
to run first: `panel3_stop_card` has already ended the runs, so recovery
|
|
1857
2925
|
cannot see them and neither take can start them. */
|
|
1858
2926
|
await killStopped(client, machineId);
|
|
2927
|
+
await reconcileOwnerSessions(client, machineId, machineHarness, new Set(inFlight.keys()));
|
|
1859
2928
|
await recoverStranded(client, tools, machineId, new Set(inFlight.keys()), hold);
|
|
2929
|
+
/* ═══ AND THE COPIES OF CARDS THAT ARE OVER. ═══ After recovery,
|
|
2930
|
+
deliberately: a run this machine is about to resume is one whose card is
|
|
2931
|
+
not finished, and the sweep asks the record after recovery has had its
|
|
2932
|
+
say about what is really still running. */
|
|
2933
|
+
await sweepFinishedWorktrees(client);
|
|
1860
2934
|
/* ═══ AND THEN WHAT SOMEBODY ELSE'S MACHINE WAS HOLDING, IF A PERSON HANDED
|
|
1861
2935
|
IT BACK. ═══ AFTER the sweep, deliberately: this machine settles its own
|
|
1862
2936
|
runs on local, pid-accurate evidence before it looks at anybody's, and a
|
|
@@ -1868,6 +2942,27 @@ export async function run(args, injected) {
|
|
|
1868
2942
|
with a brief and a report behind it, comes before work that has not
|
|
1869
2943
|
started. */
|
|
1870
2944
|
await takeHandedBack(client, tools, machineId, new Set(inFlight.keys()), hold);
|
|
2945
|
+
/* Every reason a conversational owner may continue is claimed together.
|
|
2946
|
+
The legacy turn and re-arm takes exclude named owners in the database. */
|
|
2947
|
+
await takeOwnerActivations(client, tools, machineId, new Set(inFlight.keys()), hold);
|
|
2948
|
+
/* ═══ AND IF THE DESIGNATION NAMES A HARNESS THIS DAEMON IS NOT, IT SAYS
|
|
2949
|
+
SO BEFORE TAKING NOTHING. ═══
|
|
2950
|
+
`panel3_take_turns` and `panel3_take_rearms` compare `p_agent` against
|
|
2951
|
+
`cliv2_orchestrator_preference` in SQL, and a mismatch is not an error
|
|
2952
|
+
there — it is zero rows, which is correct and which is also exactly what
|
|
2953
|
+
a quiet machine with no work waiting looks like. Proven on the record
|
|
2954
|
+
(.bugs/.resolved/20260826-daemon-ignores-the-harness-switch): with the
|
|
2955
|
+
designation on `codex` and this daemon on `claude`, a waiting turn was
|
|
2956
|
+
never claimed for over two minutes, the daemon printed nothing, and the
|
|
2957
|
+
card sat under `Working` with a healthy machine online.
|
|
2958
|
+
|
|
2959
|
+
`harness()` is read once at startup and cannot change under a running
|
|
2960
|
+
process (see `machineHarness` above), while the designation is a chip a
|
|
2961
|
+
person clicks in the app at any moment. So the two CAN disagree, and
|
|
2962
|
+
ux.md's forbidden state — "nothing will act on it and nothing on screen
|
|
2963
|
+
says so" — is reached the moment they do. This is the daemon's own
|
|
2964
|
+
screen saying so. */
|
|
2965
|
+
await reportHarnessMismatch(client, machineId, machineHarness);
|
|
1871
2966
|
/* THE MACHINE ID GOES IN because the take writes the run row, and a run has
|
|
1872
2967
|
to say where it is running: the exclusion is cross-machine and recovery is
|
|
1873
2968
|
per-machine, so a row with nobody's machine on it could be neither. */
|