@ctrl-spc/cs 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/codex-home.js +10 -6
- package/dist/config.js +17 -0
- package/dist/index.js +93 -12
- package/dist/login.js +9 -1
- package/dist/mcp.js +186 -28
- 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 +146 -3
- package/dist/panel3/run.js +638 -71
- package/dist/panel3/say.js +10 -10
- package/dist/panel3/show.js +195 -22
- package/dist/panel3/spawn.js +5 -2
- package/dist/panel3/tools.js +326 -5
- package/dist/presence.js +8 -0
- package/dist/skills.js +174 -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,11 +132,12 @@
|
|
|
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, ownerActivationPrompt, readBackPrompt, presentedArtifactAnswerContext, resumePrompt, retryPrompt, } from './prompt.js';
|
|
136
|
-
import { ASK_CONTENT_COLUMNS, attachmentLine, loadAttachments, loadOutputNames, outputOf, withAskContent, } 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';
|
|
141
142
|
import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
|
|
142
143
|
import { startToolsServer } from './tools.js';
|
|
@@ -145,7 +146,7 @@ import { getMachineIdentity, scratchDir } from '../config.js';
|
|
|
145
146
|
import { listCodebases } from '../codebases.js';
|
|
146
147
|
import { killTree, processIsAlive } from '../win-shell.js';
|
|
147
148
|
import { hostname, uptime } from 'node:os';
|
|
148
|
-
const USAGE = 'usage:
|
|
149
|
+
const USAGE = 'usage: run [--once]';
|
|
149
150
|
/** How long between takes. Short, because it is the whole delay between a user
|
|
150
151
|
* sending and a card showing an agent on it, and the take is one small indexed
|
|
151
152
|
* read against the user's own rows. */
|
|
@@ -224,6 +225,62 @@ async function rearmedArtifactAnswer(client, askId) {
|
|
|
224
225
|
? null
|
|
225
226
|
: artifactAnswer(ask.related_artifact_id, ask.related_artifact_revision, ask.selected_options);
|
|
226
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
|
+
}
|
|
227
284
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
228
285
|
/**
|
|
229
286
|
* ═══ NOTHING IN THIS FILE STAMPS A COLUMN FROM THIS MACHINE'S CLOCK ANY MORE,
|
|
@@ -292,7 +349,7 @@ function byCard(taken) {
|
|
|
292
349
|
* `levelOnePrompt` for the measured failure this closes.
|
|
293
350
|
*
|
|
294
351
|
* THE NAME IS RESOLVED, NOT READ OFF THE RECEIPT, through the one function
|
|
295
|
-
* `
|
|
352
|
+
* `cs show` uses for it. `label` is written when the thing is made and the
|
|
296
353
|
* coordinator may rename the thing on the next turn; printing the label then
|
|
297
354
|
* hands the agent the name of a different, real object. One owner for that,
|
|
298
355
|
* because two would eventually disagree about which name a card is showing.
|
|
@@ -344,6 +401,75 @@ export const briefFor = (title, turns, produced, attachments, codebases = null)
|
|
|
344
401
|
identity: codebase.gitRemoteUrl,
|
|
345
402
|
located: hasCheckoutForCodebase(codebase),
|
|
346
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
|
+
};
|
|
347
473
|
// ---------------------------------------------------------------------------
|
|
348
474
|
// WRITES. Every one goes through the shared guard in `client.ts`, which returns
|
|
349
475
|
// the rows or throws. `.select()` on each is what makes that possible: a
|
|
@@ -554,7 +680,7 @@ async function runNow(client, runId) {
|
|
|
554
680
|
* ═══ NEVER INTO `report`, WHICH IS THE RUN'S OWN WORDS. ═══ `report` is what a
|
|
555
681
|
* respawn is handed, and this build starts settled runs again — an answered
|
|
556
682
|
* question does exactly that — so a reason written there would destroy the state
|
|
557
|
-
* the next attempt was going to resume from, and `
|
|
683
|
+
* the next attempt was going to resume from, and `cs show` would print the
|
|
558
684
|
* daemon's sentence under a heading that says the run wrote it. One owner per
|
|
559
685
|
* column: the run writes `report`, whoever ended it writes `failed_because`.
|
|
560
686
|
*
|
|
@@ -615,7 +741,7 @@ async function setCardState(client, cardId, state) {
|
|
|
615
741
|
* ═══ THERE IS NOTHING TO UNDO HERE, AND THAT IS THE POINT. ═══ The take wrote
|
|
616
742
|
* the run row in the same statement that leased the turns, so this function is
|
|
617
743
|
* never in the position of holding a lease it cannot honour: whatever it fails
|
|
618
|
-
* at, the row exists, `
|
|
744
|
+
* at, the row exists, `cs show` can see it, and recovery can reach it. The
|
|
619
745
|
* earlier version wrote the row two round trips later and had to hand the turns
|
|
620
746
|
* back on failure — which covered a thrown error and not a kill, and a kill in
|
|
621
747
|
* that window stranded the card forever.
|
|
@@ -653,8 +779,22 @@ async function answerCard(client, tools, machineId, cardId, turns) {
|
|
|
653
779
|
database, whose messages name tables and columns and never a local path, so
|
|
654
780
|
constraint 6 is satisfied without a level fork here. */
|
|
655
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;
|
|
656
794
|
try {
|
|
657
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);
|
|
658
798
|
}
|
|
659
799
|
catch (error) {
|
|
660
800
|
const why = error instanceof Error ? error.message : String(error);
|
|
@@ -670,9 +810,12 @@ async function answerCard(client, tools, machineId, cardId, turns) {
|
|
|
670
810
|
/* AN EMPTY DIRECTORY OF THE USER'S OWN, because level 1 has no code tool to
|
|
671
811
|
use a real one with, and the daemon's inherited cwd under a launchd login
|
|
672
812
|
item is the filesystem root. */
|
|
673
|
-
const started = startAgent(brief, LEVEL, tools.urlFor(runId),
|
|
813
|
+
const started = startAgent(withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd);
|
|
674
814
|
out(`run ${runId} level ${LEVEL} ${started.pid ? `pid ${started.pid}` : 'no process'}`);
|
|
675
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. */
|
|
676
819
|
await recordProcess(client, runId, started.pid, brief);
|
|
677
820
|
}
|
|
678
821
|
catch (error) {
|
|
@@ -720,16 +863,178 @@ async function codebaseOfRun(client, runId) {
|
|
|
720
863
|
}
|
|
721
864
|
return codebase;
|
|
722
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
|
+
}
|
|
723
1027
|
/** One directory rule for every spawn whose level and ownership are known. */
|
|
724
1028
|
async function workingDirectory(client, runId, level, isOwner, knownCodebase) {
|
|
725
|
-
if (level === 1)
|
|
726
|
-
return scratchDir();
|
|
1029
|
+
if (level === 1) {
|
|
1030
|
+
return { cwd: scratchDir(), block: await coordinatorGitBlock(client, runId), card: null };
|
|
1031
|
+
}
|
|
727
1032
|
if (knownCodebase === null) {
|
|
728
1033
|
if (level === 2 && isOwner)
|
|
729
|
-
return scratchDir();
|
|
1034
|
+
return { cwd: scratchDir(), block: [], card: null };
|
|
730
1035
|
throw new Error('This code work does not name a registered project codebase.');
|
|
731
1036
|
}
|
|
732
|
-
return
|
|
1037
|
+
return cardWorktree(client, runId, knownCodebase ?? await codebaseOfRun(client, runId));
|
|
733
1038
|
}
|
|
734
1039
|
/**
|
|
735
1040
|
* ═══ ONE AGENT, STARTED UNDER ANOTHER THAT IS STILL RUNNING. ═══
|
|
@@ -777,13 +1082,18 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
|
|
|
777
1082
|
throw new Error(why);
|
|
778
1083
|
}
|
|
779
1084
|
const level = row.run_level;
|
|
780
|
-
let
|
|
1085
|
+
let where;
|
|
781
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;
|
|
782
1091
|
try {
|
|
783
|
-
|
|
1092
|
+
where = await workingDirectory(client, row.run_id, level, level === 2, codebase);
|
|
784
1093
|
prompt = level === 2
|
|
785
1094
|
? await initialOwnerPrompt(client, row.run_card_id, parentRunId, brief)
|
|
786
1095
|
: brief;
|
|
1096
|
+
rules = await standingRulesFor(client, row.run_id);
|
|
787
1097
|
}
|
|
788
1098
|
catch (error) {
|
|
789
1099
|
const why = error instanceof Error ? error.message : String(error);
|
|
@@ -792,7 +1102,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
|
|
|
792
1102
|
}
|
|
793
1103
|
const processToken = row.process_token ?? undefined;
|
|
794
1104
|
const isOwner = level === 2;
|
|
795
|
-
const started = startAgent(prompt, level, tools.urlFor(row.run_id, processToken), cwd, isOwner ? { ownerId: row.run_id } : undefined);
|
|
1105
|
+
const started = startAgent(withStandingRules(rules, prompt, where.block), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined);
|
|
796
1106
|
if (started.pid === null) {
|
|
797
1107
|
/* THERE IS A ROW AND THERE IS NO PROCESS, which is the one shape the record
|
|
798
1108
|
must never be left in quietly. The answer is already settled — nothing ran
|
|
@@ -1174,14 +1484,20 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
|
|
|
1174
1484
|
* about the failure it was holding.
|
|
1175
1485
|
*/
|
|
1176
1486
|
async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
1177
|
-
/* ═══ THE SWEEP
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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());
|
|
1185
1501
|
const { data, error } = await client.rpc('panel3_resume', {
|
|
1186
1502
|
p_run_id: runId,
|
|
1187
1503
|
p_machine_id: machineId,
|
|
@@ -1202,39 +1518,34 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
|
1202
1518
|
await giveUp(client, runId, why);
|
|
1203
1519
|
throw new Error(why);
|
|
1204
1520
|
}
|
|
1205
|
-
/*
|
|
1206
|
-
the
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
let
|
|
1210
|
-
|
|
1211
|
-
|
|
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);
|
|
1212
1528
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
attempts at one, which is the bound the retry is under, undone by the
|
|
1234
|
-
one path that could not start. See `endRun`. */
|
|
1235
|
-
await endRun(client, level, runId, claimed.run_card_id, why);
|
|
1236
|
-
throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
|
|
1237
|
-
}
|
|
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}`);
|
|
1238
1549
|
}
|
|
1239
1550
|
/* WHAT IT SENT OTHERS TO DO, FROM THE RECORD. Level 3 has no `dispatch`, so it
|
|
1240
1551
|
has no children to have: null says that, where an empty list would say it
|
|
@@ -1247,14 +1558,36 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
|
1247
1558
|
time: ux.md's single most expensive failure. `startRearmed` reads it the
|
|
1248
1559
|
same way, for the same reason. */
|
|
1249
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
|
+
}
|
|
1250
1583
|
const started = startAgent(
|
|
1251
1584
|
/* ═══ WHY IT DIED IS WHAT DIFFERS, AND IT IS TOLD THE TRUTH ABOUT IT. ═══
|
|
1252
1585
|
`resumePrompt` opens by saying the machine went down, which is true of the
|
|
1253
1586
|
sweep and false of a retry: the daemon that watched this harness exit is
|
|
1254
1587
|
still running. See `retryPrompt`. */
|
|
1255
|
-
afterPid === null
|
|
1588
|
+
withStandingRules(rules, afterPid === null
|
|
1256
1589
|
? resumePrompt(claimed.run_brief, claimed.run_report, children)
|
|
1257
|
-
: 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);
|
|
1258
1591
|
if (started.pid === null) {
|
|
1259
1592
|
/* THE CLAIM HAPPENED AND NO PROCESS DID, which is the one shape the record
|
|
1260
1593
|
must never be left in quietly. Same handling as a dispatch that could not
|
|
@@ -1302,7 +1635,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
|
|
|
1302
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
|
|
1303
1636
|
* started would sit `running` with no process and its question already spent. So
|
|
1304
1637
|
* every failure below ends the run with its reason, which is visible on the card
|
|
1305
|
-
* and in `
|
|
1638
|
+
* and in `cs show`, rather than quietly hoping the next poll finds it.
|
|
1306
1639
|
*/
|
|
1307
1640
|
async function startRearmed(client, tools, machineId, row) {
|
|
1308
1641
|
const level = row.run_level === 1 ? 1 : row.run_level === 2 ? 2 : 3;
|
|
@@ -1311,16 +1644,16 @@ async function startRearmed(client, tools, machineId, row) {
|
|
|
1311
1644
|
await giveUp(client, row.run_id, why);
|
|
1312
1645
|
throw new Error(why);
|
|
1313
1646
|
}
|
|
1314
|
-
let
|
|
1647
|
+
let where;
|
|
1315
1648
|
try {
|
|
1316
|
-
|
|
1649
|
+
where = await workingDirectory(client, row.run_id, level, false);
|
|
1317
1650
|
}
|
|
1318
1651
|
catch (error) {
|
|
1319
1652
|
/* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
|
|
1320
1653
|
═══ Constraint 6. `workingCopy()` is already careful about this and says
|
|
1321
1654
|
so; `scratchDir()` is not — it is `mkdirSync`, whose EACCES and ENOTDIR
|
|
1322
1655
|
messages name the directory they failed on — and `failed_because` is a
|
|
1323
|
-
column `
|
|
1656
|
+
column `cs show` prints. So the machine's own error is kept for stderr
|
|
1324
1657
|
and the record is told only what is true and shareable. */
|
|
1325
1658
|
const said = error instanceof Error ? error.message : String(error);
|
|
1326
1659
|
const why = level === 1
|
|
@@ -1339,6 +1672,14 @@ async function startRearmed(client, tools, machineId, row) {
|
|
|
1339
1672
|
const deliveredArtifact = row.ask_id !== null && row.mine
|
|
1340
1673
|
? await rearmedArtifactAnswer(client, row.ask_id)
|
|
1341
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);
|
|
1342
1683
|
/* ═══ THREE REASONS, AND THE ROW SAYS WHICH. ═══ No question is ux.md's third
|
|
1343
1684
|
re-arm: everybody it sent has finished, and it is started to read them back.
|
|
1344
1685
|
`children` cannot be null on that path — only a run with children is ever
|
|
@@ -1347,9 +1688,21 @@ async function startRearmed(client, tools, machineId, row) {
|
|
|
1347
1688
|
const prompt = row.ask_id === null
|
|
1348
1689
|
? readBackPrompt(row.run_brief, row.run_report, children ?? [])
|
|
1349
1690
|
: row.mine
|
|
1350
|
-
? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '', deliveredArtifact)
|
|
1691
|
+
? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '', deliveredArtifact, landing)
|
|
1351
1692
|
: escalationPrompt(row.run_brief, row.run_report, children, row.ask_id, row.question ?? '');
|
|
1352
|
-
|
|
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);
|
|
1353
1706
|
if (started.pid === null) {
|
|
1354
1707
|
const answer = await started.answered;
|
|
1355
1708
|
const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
|
|
@@ -1469,7 +1822,7 @@ export async function initialOwnerPrompt(client, cardId, launcherRunId, brief) {
|
|
|
1469
1822
|
}
|
|
1470
1823
|
/** Only facts delivered by this activation enter an already-resumed native
|
|
1471
1824
|
* conversation. Older visible turns are already in that conversation. */
|
|
1472
|
-
export function ownerContinuationPrompt(brief, report, events, children, delivered) {
|
|
1825
|
+
export function ownerContinuationPrompt(brief, report, events, children, delivered, landing = null) {
|
|
1473
1826
|
const messages = events.filter((event) => event.kind === 'user' && event.needsReply);
|
|
1474
1827
|
return [
|
|
1475
1828
|
'CONTINUE THE CTRL+SPC CONVERSATION YOU ALREADY OWN.',
|
|
@@ -1485,6 +1838,7 @@ export function ownerContinuationPrompt(brief, report, events, children, deliver
|
|
|
1485
1838
|
`Question ${delivered.id}: ${delivered.question}`,
|
|
1486
1839
|
`Answer: ${delivered.answer ?? '(no answer text)'}`,
|
|
1487
1840
|
...presentedArtifactAnswerContext(delivered.artifactAnswer),
|
|
1841
|
+
...landingOutcomeContext(landing),
|
|
1488
1842
|
] : [
|
|
1489
1843
|
'',
|
|
1490
1844
|
'A WORKER NEEDS YOUR DECISION',
|
|
@@ -1528,7 +1882,16 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
|
|
|
1528
1882
|
return null;
|
|
1529
1883
|
const machineHarness = harness();
|
|
1530
1884
|
const resumeSessionId = resumableOwnerSessionId(candidate, machineId, machineHarness);
|
|
1531
|
-
|
|
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
|
+
}
|
|
1532
1895
|
const { data, error } = await client.rpc('panel3_take_owner_activation', {
|
|
1533
1896
|
p_run_id: runId,
|
|
1534
1897
|
p_machine_id: machineId,
|
|
@@ -1553,8 +1916,60 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
|
|
|
1553
1916
|
mine: claimed.mine === true,
|
|
1554
1917
|
artifactAnswer: currentArtifactAnswer,
|
|
1555
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);
|
|
1556
1971
|
const prompt = resumeSessionId
|
|
1557
|
-
? ownerContinuationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered)
|
|
1972
|
+
? ownerContinuationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered, landing)
|
|
1558
1973
|
: ownerActivationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered !== null && !delivered.mine
|
|
1559
1974
|
? { id: delivered.id, question: delivered.question }
|
|
1560
1975
|
: null, currentArtifactAnswer,
|
|
@@ -1564,8 +1979,8 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
|
|
|
1564
1979
|
The sentences it adds say what to do and never why, because those three
|
|
1565
1980
|
are not the same event and `prompt.ts` exists to stop an agent being
|
|
1566
1981
|
told an untrue reason for its own restart. */
|
|
1567
|
-
afterPid !== null);
|
|
1568
|
-
const started = startAgent(prompt, 2, tools.urlFor(runId, claimed.process_token), cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) });
|
|
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 } : {}) });
|
|
1569
1984
|
if (started.pid === null) {
|
|
1570
1985
|
const answer = await started.answered;
|
|
1571
1986
|
const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
|
|
@@ -2253,7 +2668,7 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
|
|
|
2253
2668
|
*
|
|
2254
2669
|
* ═══ ONE LOOP, TWO CALLERS, AND THAT IS THE WHOLE OF recovery-1 SLICE 4. ═══
|
|
2255
2670
|
* `startPanel` below is `cs start`'s entry and hands in the session the
|
|
2256
|
-
* installed CLI already holds. `
|
|
2671
|
+
* installed CLI already holds. `panel3/cli.js run` is the acceptance harness's entry and
|
|
2257
2672
|
* signs in from the environment against its own isolated account. Neither is a
|
|
2258
2673
|
* copy of the other: a second poll loop would be two things leasing the same
|
|
2259
2674
|
* turns with two ideas of what recovery may reap.
|
|
@@ -2262,6 +2677,135 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
|
|
|
2262
2677
|
* already has one. Its presence is also what says THIS PROCESS IS NOT v3's, so
|
|
2263
2678
|
* the signals belong to somebody else — see the handler block below.
|
|
2264
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
|
+
}
|
|
2265
2809
|
export async function run(args, injected) {
|
|
2266
2810
|
let once = false;
|
|
2267
2811
|
for (const arg of args) {
|
|
@@ -2358,7 +2902,7 @@ export async function run(args, injected) {
|
|
|
2358
2902
|
for (;;) {
|
|
2359
2903
|
/* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
|
|
2360
2904
|
here throws on a network or database error, by design (constraint 7), and
|
|
2361
|
-
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.
|
|
2362
2906
|
Inside `cs start` that is no longer an honest outcome twice over: it would
|
|
2363
2907
|
take the v2 presence heartbeat down with it, and it would leave a stranded
|
|
2364
2908
|
card printing `cs start` at a person who IS running `cs start`, which is
|
|
@@ -2382,6 +2926,11 @@ export async function run(args, injected) {
|
|
|
2382
2926
|
await killStopped(client, machineId);
|
|
2383
2927
|
await reconcileOwnerSessions(client, machineId, machineHarness, new Set(inFlight.keys()));
|
|
2384
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);
|
|
2385
2934
|
/* ═══ AND THEN WHAT SOMEBODY ELSE'S MACHINE WAS HOLDING, IF A PERSON HANDED
|
|
2386
2935
|
IT BACK. ═══ AFTER the sweep, deliberately: this machine settles its own
|
|
2387
2936
|
runs on local, pid-accurate evidence before it looks at anybody's, and a
|
|
@@ -2396,6 +2945,24 @@ export async function run(args, injected) {
|
|
|
2396
2945
|
/* Every reason a conversational owner may continue is claimed together.
|
|
2397
2946
|
The legacy turn and re-arm takes exclude named owners in the database. */
|
|
2398
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);
|
|
2399
2966
|
/* THE MACHINE ID GOES IN because the take writes the run row, and a run has
|
|
2400
2967
|
to say where it is running: the exclusion is cross-machine and recovery is
|
|
2401
2968
|
per-machine, so a row with nobody's machine on it could be neither. */
|