@ctrl-spc/cs 0.7.12 → 0.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.js +6 -122
- package/dist/folders.js +97 -9
- package/dist/panel3/checkout.js +100 -8
- package/dist/panel3/presence.js +7 -0
- package/dist/panel3/prompt.js +18 -8
- package/dist/panel3/run.js +146 -70
- package/dist/panel3/tools.js +28 -5
- package/dist/presence.js +54 -0
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { ensureAutostart } from './autostart.js';
|
|
2
|
-
import { claimDaemonLock, releaseDaemonLock } from './daemon-lock.js';
|
|
3
2
|
import { liveClient, startPresence, stopPresence } from './presence.js';
|
|
4
|
-
/* ═══ THE ONE IMPORT ANYTHING OUTSIDE `panel3/` MAKES INTO IT. ═══ Named in
|
|
5
|
-
`.implementations/19-agent-panel-v3/conventions.md` and enforced by
|
|
6
|
-
`test/panel3-isolation.contract.test.mjs`, whose MOUNTS map allows this file
|
|
7
|
-
exactly this specifier. A second one fails the suite. See `startPanel`. */
|
|
8
|
-
import { startPanel } from './panel3/run.js';
|
|
9
3
|
/**
|
|
10
4
|
* The terminal presence daemon (`cs start`). Comes online and heartbeats until
|
|
11
5
|
* the process is signalled. Under launchd/KeepAlive (autostart) a hard crash is
|
|
@@ -22,89 +16,12 @@ import { startPanel } from './panel3/run.js';
|
|
|
22
16
|
* clients in one process is two refresh loops on one rotating refresh token,
|
|
23
17
|
* both writing `session.json`.
|
|
24
18
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* would sweep the other's live runs. `cs start` is the launch the ruling names,
|
|
28
|
-
* and `ensureAutostart` below installs exactly it, so an existing login item
|
|
29
|
-
* starts doing the right thing with nothing else typed.
|
|
19
|
+
* Companion and the terminal daemon both use the same presence lifecycle,
|
|
20
|
+
* which owns the card worker, session and per-machine lock.
|
|
30
21
|
*/
|
|
31
22
|
export async function runDaemon() {
|
|
32
|
-
/* One daemon per config dir. See daemon-lock.ts for the flicker this stops.
|
|
33
|
-
Exits rather than replacing the holder: under launchd's KeepAlive a
|
|
34
|
-
replacement would be restarted and would then replace the replacer. */
|
|
35
|
-
const lock = claimDaemonLock();
|
|
36
|
-
if (lock.held) {
|
|
37
|
-
console.error(`cs start is already running on this computer (pid ${lock.pid}). Stop it first to start another.`);
|
|
38
|
-
process.exitCode = 1;
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
ensureAutostart(); // default-on: install the login item unless the user opted out
|
|
42
23
|
const { machineName, agents } = await startPresence();
|
|
43
|
-
|
|
44
|
-
`startPresence` used to return the client it had just built and this line
|
|
45
|
-
passed that value to `startPanel`, which held it for the process's life. The
|
|
46
|
-
heartbeat's catch block REBUILDS that client when `session.json` comes to
|
|
47
|
-
hold a different refresh token, and since the rebuild guard landed a
|
|
48
|
-
successful rebuild is the normal path rather than a throw. The value handed
|
|
49
|
-
over here was therefore a client the database refuses and nobody watches,
|
|
50
|
-
while the panel's two-second poll went on writing `panel3_machines` with it.
|
|
51
|
-
That table took 37,051 writes in the incident window, the largest single
|
|
52
|
-
share of it.
|
|
53
|
-
`liveClient` is presence's own accessor, so the panel asks the module that
|
|
54
|
-
owns the presence lifecycle rather than being told once. It cannot answer
|
|
55
|
-
null here: `startPresence` has returned, so presence is running. */
|
|
56
|
-
/* ═══ AND THE LAST CLIENT IT SAW IS KEPT, FOR THE ONE READ THAT HAPPENS AFTER
|
|
57
|
-
PRESENCE HAS ALREADY STOPPED. ═══ `startPanel` returns `stop: () =>
|
|
58
|
-
stopListening(current(), ...)`, and `current()` is evaluated in the arrow,
|
|
59
|
-
BEFORE `stopListening` is entered, so `stopListening`'s own best-effort
|
|
60
|
-
try/catch cannot cover it. On `SIGNED_OUT` both watchers are bound to this
|
|
61
|
-
same client and auth-js runs them in registration order: presence's fires
|
|
62
|
-
first and `stopPresence` sets its module singleton to null synchronously,
|
|
63
|
-
so by the time the daemon's watcher reaches `shutdown` -> `panel.stop()`
|
|
64
|
-
the getter below has nothing live to answer with. Throwing there is a throw
|
|
65
|
-
inside `shutdown` before its first await, which `void shutdown(1)` does not
|
|
66
|
-
contain: `panel3_machines.stopped_at` never gets written, the card goes on
|
|
67
|
-
saying an agent is working, `Promise.all` rejects, `process.exit` is never
|
|
68
|
-
reached, and the panel's `for(;;)` spins on a caught-and-logged failure
|
|
69
|
-
every two seconds forever. launchd's `KeepAlive` cannot help a process that
|
|
70
|
-
never exits. `shutdown()` must reach `process.exit` on every path.
|
|
71
|
-
|
|
72
|
-
THE FALLBACK IS THE LAST CLIENT PRESENCE ITSELF HELD, not the one it built
|
|
73
|
-
at startup: a rebuild replaces `p.client`, and a goodbye written with the
|
|
74
|
-
pre-rebuild snapshot is the same refused write this branch exists to stop.
|
|
75
|
-
Recording it on every successful read is what keeps it current without a
|
|
76
|
-
second subscription to the presence lifecycle.
|
|
77
|
-
|
|
78
|
-
THE THROW IS NOT WEAKENED FOR THE POLL, and that is the point of ordering
|
|
79
|
-
the two lines this way. A poll only reaches the fallback once presence has
|
|
80
|
-
stopped, which in this process means a shutdown is already in flight and
|
|
81
|
-
the exit is milliseconds away; every other poll, for the whole life of the
|
|
82
|
-
daemon, reads a live client or fails loudly exactly as before. What the
|
|
83
|
-
fallback buys is the goodbye write, which `stopListening` documents as
|
|
84
|
-
best-effort precisely because it is the last thing this process does.
|
|
85
|
-
|
|
86
|
-
THE ONE THING THAT WINDOW COSTS, NAMED RATHER THAN LEFT TO BE REDISCOVERED:
|
|
87
|
-
a poll landing in it used to throw at the getter and be swallowed by the
|
|
88
|
-
per-poll catch, so it could not write at all. Now it takes the fallback and
|
|
89
|
-
reaches `sayListening`, whose upsert deliberately sends `stopped_at: null`
|
|
90
|
-
on every poll, since that is what lets a restarted daemon come back on a row
|
|
91
|
-
stamped as gone. If such a poll's write lands AFTER `stopListening`'s stamp,
|
|
92
|
-
it erases the goodbye and the row reads listening again. That is bounded and
|
|
93
|
-
self-healing: `LISTENING_WINDOW_MS` is 15s, so the freshness check calls the
|
|
94
|
-
machine gone 15 seconds later whatever the column says, and the process is
|
|
95
|
-
already exiting. Not worth a second seam to close, and worth knowing about
|
|
96
|
-
if a machine is ever seen lingering for a few seconds after `cs start` ends. */
|
|
97
|
-
let lastKnownClient = liveClient();
|
|
98
|
-
const panel = startPanel(() => {
|
|
99
|
-
const live = liveClient();
|
|
100
|
-
if (live) {
|
|
101
|
-
lastKnownClient = live;
|
|
102
|
-
return live;
|
|
103
|
-
}
|
|
104
|
-
if (!lastKnownClient)
|
|
105
|
-
throw new Error('the presence loop is not running, so there is no session to work through');
|
|
106
|
-
return lastKnownClient;
|
|
107
|
-
});
|
|
24
|
+
ensureAutostart(); // default-on: install the login item unless the user opted out
|
|
108
25
|
console.log(`CTRL+SPC — this computer: ${machineName}`);
|
|
109
26
|
console.log(`Agents detected: ${agents.length ? agents.join(', ') : 'none'}`);
|
|
110
27
|
console.log('Online. Heartbeating presence and answering cards. Ctrl-C to stop.');
|
|
@@ -113,46 +30,13 @@ export async function runDaemon() {
|
|
|
113
30
|
if (stopping)
|
|
114
31
|
return;
|
|
115
32
|
stopping = true;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
machines chip, and `panel3_machines.stopped_at` for the card that would
|
|
119
|
-
otherwise go on saying an agent is working. Two handlers each calling
|
|
120
|
-
`process.exit` would let whichever finished first kill the other's write,
|
|
121
|
-
so the panel's own signal handling stands down when it is started from
|
|
122
|
-
here (see `run()`). Both are best-effort and neither throws, which is why
|
|
123
|
-
they can settle together. */
|
|
124
|
-
await Promise.all([panel.stop(), stopPresence()]);
|
|
125
|
-
releaseDaemonLock();
|
|
33
|
+
// Shared presence stops the card worker and both readiness records.
|
|
34
|
+
await stopPresence();
|
|
126
35
|
process.exit(code);
|
|
127
36
|
}
|
|
128
37
|
process.on('SIGINT', () => void shutdown());
|
|
129
38
|
process.on('SIGTERM', () => void shutdown());
|
|
130
|
-
|
|
131
|
-
own three loops on `SIGNED_OUT`, which leaves this process holding a panel
|
|
132
|
-
that would go on upserting `panel3_machines` every few seconds with a token
|
|
133
|
-
the database refuses. It shares this client, so it hears the same event: one
|
|
134
|
-
shutdown, both halves, and the exit says the daemon needs a person rather
|
|
135
|
-
than pretending it is still online. Non-zero because this is a failure, not
|
|
136
|
-
a Ctrl-C. The login item's `KeepAlive` restarts either way, and that is the
|
|
137
|
-
point: a restart refuses at `getClient` after one refresh attempt and exits
|
|
138
|
-
again, so an unattended machine retries on launchd's throttle instead of
|
|
139
|
-
polling four times a second forever. */
|
|
140
|
-
/* ═══ BOUND TO WHATEVER CLIENT IS LIVE AT THIS INSTANT, WHICH IS WHY IT IS
|
|
141
|
-
READ AND NOT CAPTURED. ═══ This used to bind to the client `startPresence`
|
|
142
|
-
returned. A rebuild replaces that client, and presence re-binds its OWN
|
|
143
|
-
watcher to the replacement (`bindSignedOutWatcher`) while this one would
|
|
144
|
-
stay on the discarded client, where `SIGNED_OUT` can never fire again. That
|
|
145
|
-
leaves the daemon's half of the shutdown deaf for the rest of the process.
|
|
146
|
-
Reading it here binds to the current one; a client rebuilt LATER is
|
|
147
|
-
presence's watcher to carry, and that one calls `stopPresence`, which clears
|
|
148
|
-
the intervals this process depends on. This is not a second terminal stop,
|
|
149
|
-
it is the same one bound to a client that exists.
|
|
150
|
-
|
|
151
|
-
THE NULL IS THROWN ON RATHER THAN SKIPPED, matching the getter above. `?.`
|
|
152
|
-
would read as though no client were an expected state, and it is not:
|
|
153
|
-
`startPresence` has returned, so presence is running. Worse, it would
|
|
154
|
-
silently drop half the shutdown in exactly the state it claims to guard
|
|
155
|
-
against, leaving a daemon that answers a dead session by doing nothing. */
|
|
39
|
+
// Presence owns sign-out cleanup; the terminal entry also exits.
|
|
156
40
|
const live = liveClient();
|
|
157
41
|
if (!live)
|
|
158
42
|
throw new Error('the presence loop is not running, so there is no session to watch');
|
package/dist/folders.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
/** Native folder chooser per OS.
|
|
3
|
-
function pickerCommand() {
|
|
4
|
-
if (
|
|
2
|
+
/** Native folder chooser per OS. */
|
|
3
|
+
export function pickerCommand(platform = process.platform) {
|
|
4
|
+
if (platform === 'darwin') {
|
|
5
5
|
return {
|
|
6
6
|
command: 'osascript',
|
|
7
7
|
args: [
|
|
@@ -13,13 +13,101 @@ function pickerCommand() {
|
|
|
13
13
|
],
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
-
if (
|
|
16
|
+
if (platform === 'win32') {
|
|
17
|
+
const source = String.raw `
|
|
18
|
+
using System;
|
|
19
|
+
using System.Runtime.InteropServices;
|
|
20
|
+
|
|
21
|
+
[ComImport]
|
|
22
|
+
[Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
|
|
23
|
+
internal class FileOpenDialog { }
|
|
24
|
+
|
|
25
|
+
[ComImport]
|
|
26
|
+
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
|
|
27
|
+
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
28
|
+
internal interface IShellItem
|
|
29
|
+
{
|
|
30
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
31
|
+
void GetParent(out IShellItem parent);
|
|
32
|
+
void GetDisplayName(uint sigdnName, out IntPtr name);
|
|
33
|
+
void GetAttributes(uint mask, out uint attributes);
|
|
34
|
+
void Compare(IShellItem other, uint hint, out int order);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[ComImport]
|
|
38
|
+
[Guid("D57C7288-D4AD-4768-BE02-9D969532D960")]
|
|
39
|
+
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
40
|
+
internal interface IFileOpenDialog
|
|
41
|
+
{
|
|
42
|
+
[PreserveSig] int Show(IntPtr owner);
|
|
43
|
+
void SetFileTypes(uint count, IntPtr filters);
|
|
44
|
+
void SetFileTypeIndex(uint index);
|
|
45
|
+
void GetFileTypeIndex(out uint index);
|
|
46
|
+
void Advise(IntPtr events, out uint cookie);
|
|
47
|
+
void Unadvise(uint cookie);
|
|
48
|
+
void SetOptions(uint options);
|
|
49
|
+
void GetOptions(out uint options);
|
|
50
|
+
void SetDefaultFolder(IShellItem folder);
|
|
51
|
+
void SetFolder(IShellItem folder);
|
|
52
|
+
void GetFolder(out IShellItem folder);
|
|
53
|
+
void GetCurrentSelection(out IShellItem item);
|
|
54
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name);
|
|
55
|
+
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name);
|
|
56
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
|
|
57
|
+
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
|
|
58
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string label);
|
|
59
|
+
void GetResult(out IShellItem item);
|
|
60
|
+
void AddPlace(IShellItem item, uint alignment);
|
|
61
|
+
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string extension);
|
|
62
|
+
void Close(int result);
|
|
63
|
+
void SetClientGuid(ref Guid guid);
|
|
64
|
+
void ClearClientData();
|
|
65
|
+
void SetFilter(IntPtr filter);
|
|
66
|
+
void GetResults(out IntPtr items);
|
|
67
|
+
void GetSelectedItems(out IntPtr items);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public static class CtrlSpcFolderPicker
|
|
71
|
+
{
|
|
72
|
+
private const uint PickFolders = 0x20;
|
|
73
|
+
private const uint ForceFileSystem = 0x40;
|
|
74
|
+
private const uint PathMustExist = 0x800;
|
|
75
|
+
private const uint FileSystemPath = 0x80058000;
|
|
76
|
+
private const int Cancelled = unchecked((int)0x800704C7);
|
|
77
|
+
|
|
78
|
+
[DllImport("user32.dll")]
|
|
79
|
+
private static extern IntPtr GetForegroundWindow();
|
|
80
|
+
|
|
81
|
+
public static string Choose()
|
|
82
|
+
{
|
|
83
|
+
IFileOpenDialog dialog = (IFileOpenDialog)new FileOpenDialog();
|
|
84
|
+
IShellItem item = null;
|
|
85
|
+
IntPtr name = IntPtr.Zero;
|
|
86
|
+
try
|
|
87
|
+
{
|
|
88
|
+
uint options;
|
|
89
|
+
dialog.GetOptions(out options);
|
|
90
|
+
dialog.SetOptions(options | PickFolders | ForceFileSystem | PathMustExist);
|
|
91
|
+
dialog.SetTitle("Choose a project folder for CTRL+SPC");
|
|
92
|
+
int result = dialog.Show(GetForegroundWindow());
|
|
93
|
+
if (result == Cancelled) return null;
|
|
94
|
+
Marshal.ThrowExceptionForHR(result);
|
|
95
|
+
dialog.GetResult(out item);
|
|
96
|
+
item.GetDisplayName(FileSystemPath, out name);
|
|
97
|
+
return Marshal.PtrToStringUni(name);
|
|
98
|
+
}
|
|
99
|
+
finally
|
|
100
|
+
{
|
|
101
|
+
if (name != IntPtr.Zero) Marshal.FreeCoTaskMem(name);
|
|
102
|
+
if (item != null) Marshal.FinalReleaseComObject(item);
|
|
103
|
+
Marshal.FinalReleaseComObject(dialog);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}`;
|
|
17
107
|
const script = [
|
|
18
|
-
|
|
19
|
-
'
|
|
20
|
-
|
|
21
|
-
'if ($dialog.ShowDialog() -eq "OK") { $dialog.SelectedPath }',
|
|
22
|
-
].join('; ');
|
|
108
|
+
`Add-Type -TypeDefinition @'\n${source}\n'@`,
|
|
109
|
+
'[CtrlSpcFolderPicker]::Choose()',
|
|
110
|
+
].join('\n');
|
|
23
111
|
return { command: 'powershell.exe', args: ['-NoProfile', '-STA', '-Command', script] };
|
|
24
112
|
}
|
|
25
113
|
return {
|
package/dist/panel3/checkout.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* two owners of it is how two answers are born.
|
|
15
15
|
*/
|
|
16
16
|
import { execFileSync } from 'node:child_process';
|
|
17
|
-
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { configDir, readCodebasePaths } from '../config.js';
|
|
20
20
|
function isDirectory(path) {
|
|
@@ -413,6 +413,8 @@ export function commitCardWork(folder,
|
|
|
413
413
|
// Name the codebase and branch in failures, without exposing a local path.
|
|
414
414
|
describe) {
|
|
415
415
|
try {
|
|
416
|
+
if (gitOperationInProgress(folder))
|
|
417
|
+
throw pathFree('A Git operation is still in progress on this card. Use recover_landing to finish the approved recovery before landing.');
|
|
416
418
|
git(folder, ['add', '-A']);
|
|
417
419
|
if (git(folder, ['status', '--porcelain']).length > 0) {
|
|
418
420
|
git(folder, ['commit', '--no-verify', '-m', 'Work from this card']);
|
|
@@ -447,28 +449,118 @@ export function mergeIntoBase(source, branch, base, codebaseName) {
|
|
|
447
449
|
The worktree holding the base may be the person's own checkout, and the
|
|
448
450
|
step below would abort on failure. So a conflicted merge already sitting
|
|
449
451
|
there stops this before anything is attempted. */
|
|
450
|
-
|
|
451
|
-
|
|
452
|
+
let inProgress;
|
|
453
|
+
try {
|
|
454
|
+
inProgress = gitOperationInProgress(holder);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
throw pathFree(`Could not inspect ${codebaseName}'s ${base} on this machine. Nothing was merged; check that working copy before retrying.`);
|
|
458
|
+
}
|
|
459
|
+
if (inProgress) {
|
|
460
|
+
throw pathFree(`A Git operation is already in progress in the copy of ${codebaseName} holding ${base} on this `
|
|
452
461
|
+ `machine. Nothing was done, and this card's work is still on ${branch}.`);
|
|
453
462
|
}
|
|
454
463
|
try {
|
|
455
464
|
git(holder, ['merge', '--no-edit', branch]);
|
|
456
465
|
}
|
|
457
466
|
catch {
|
|
467
|
+
const conflicts = git(holder, ['diff', '--name-only', '--diff-filter=U']).length > 0;
|
|
458
468
|
/* ITS OWN TRY, SO A FAILED ABORT CANNOT REPLACE THE COMPOSED FAILURE with
|
|
459
469
|
git's own text, which names a folder. */
|
|
470
|
+
if (gitOk(holder, ['rev-parse', '--verify', 'MERGE_HEAD'])) {
|
|
471
|
+
try {
|
|
472
|
+
git(holder, ['merge', '--abort']);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
throw pathFree(`The merge in ${codebaseName} could not be rolled back. ${base} needs recovery before retrying; this card's branch is preserved.`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
throw Object.assign(pathFree(`This card's work will not go back onto ${base} in ${codebaseName} cleanly. Nothing was `
|
|
479
|
+
+ `merged, ${base} is where it was, and the work is still on ${branch}.`), { mergeConflicts: conflicts });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function gitOperationInProgress(folder) {
|
|
483
|
+
return ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply', 'sequencer']
|
|
484
|
+
.some(name => existsSync(git(folder, ['rev-parse', '--path-format=absolute', '--git-path', name])));
|
|
485
|
+
}
|
|
486
|
+
const reconciliationMessage = (card) => `CTRL+SPC: reconcile ${card.branch} with ${card.base}`;
|
|
487
|
+
/** Git owns the durable recovery state. Its merge message identifies our merge;
|
|
488
|
+
* a user's in-progress operation is never continued or aborted by recovery. */
|
|
489
|
+
export function ownsReconciliation(card) {
|
|
490
|
+
if (!gitOk(card.folder, ['rev-parse', '--verify', 'MERGE_HEAD']))
|
|
491
|
+
return false;
|
|
492
|
+
try {
|
|
493
|
+
const message = git(card.folder, ['rev-parse', '--path-format=absolute', '--git-path', 'MERGE_MSG']);
|
|
494
|
+
return readFileSync(message, 'utf8').split('\n')[0] === reconciliationMessage(card);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
throw pathFree('Could not read this card’s merge recovery state. Its working copy needs recovery on this machine.');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function checkCardBranch(card) {
|
|
501
|
+
if (git(card.folder, ['branch', '--show-current']) !== card.branch || card.branch === card.base) {
|
|
502
|
+
throw pathFree('The card is no longer on its assigned branch. Restore its working copy before retrying the approved merge.');
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/** Prepare only the card's working copy. The sandboxed agent resolves ordinary
|
|
506
|
+
* files; the product keeps ownership of the index, merge state, and base branch. */
|
|
507
|
+
export function prepareCardReconciliation(card) {
|
|
508
|
+
try {
|
|
509
|
+
checkCardBranch(card);
|
|
510
|
+
if (gitOperationInProgress(card.folder)) {
|
|
511
|
+
if (!ownsReconciliation(card))
|
|
512
|
+
throw pathFree('Another Git operation is already in progress on this card. It was left untouched.');
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
if (git(card.folder, ['status', '--porcelain']).length > 0) {
|
|
516
|
+
throw pathFree('The card has uncommitted changes. Preserve and verify them before retrying the approved merge with recover_landing(action="finish").');
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
git(card.folder, ['merge', '--no-commit', '--no-ff', '-m', reconciliationMessage(card), `refs/heads/${card.base}`]);
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
if (!ownsReconciliation(card))
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const conflicts = git(card.folder, ['diff', '--name-only', '--diff-filter=U']);
|
|
527
|
+
return { conflicts: conflicts ? conflicts.split('\n').length : 0 };
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
if (error?.pathFree)
|
|
531
|
+
throw error;
|
|
532
|
+
throw pathFree(`Could not prepare merge recovery on ${card.branch}. The work remains on its branch; retry recovery on this machine.`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/** Called only after the owner reports verification under the active token. */
|
|
536
|
+
export function finishCardReconciliation(card) {
|
|
537
|
+
try {
|
|
538
|
+
checkCardBranch(card);
|
|
539
|
+
if (!gitOperationInProgress(card.folder))
|
|
540
|
+
return;
|
|
541
|
+
if (!ownsReconciliation(card))
|
|
542
|
+
throw pathFree('Another Git operation is already in progress on this card. It was left untouched.');
|
|
543
|
+
// Stage resolved edits, including deletions. Refuse remaining conflict
|
|
544
|
+
// markers before committing, even though staging clears Git's U entries.
|
|
545
|
+
git(card.folder, ['add', '-A']);
|
|
460
546
|
try {
|
|
461
|
-
git(
|
|
547
|
+
git(card.folder, ['diff', '--cached', '--check']);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
throw pathFree('The resolution still contains conflict markers or whitespace errors. Fix the files, verify the result, and retry recover_landing(action="finish"). Nothing was merged onto the base.');
|
|
462
551
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
552
|
+
git(card.folder, ['commit', '--no-edit']);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
if (error?.pathFree)
|
|
556
|
+
throw error;
|
|
557
|
+
throw pathFree(`Could not finish merge recovery on ${card.branch}. The resolution is preserved; retry recovery on this machine.`);
|
|
466
558
|
}
|
|
467
559
|
}
|
|
468
560
|
export function settleCardWorktree(folder) {
|
|
469
561
|
// Completion is not permission to commit. Preserve unfinished Git work,
|
|
470
562
|
// including staged and untracked files, for the person to review or resume.
|
|
471
|
-
if (git(folder, ['status', '--porcelain']).length > 0)
|
|
563
|
+
if (gitOperationInProgress(folder) || git(folder, ['status', '--porcelain']).length > 0)
|
|
472
564
|
return false;
|
|
473
565
|
// Only ignored build/dependency files can remain in a clean copy.
|
|
474
566
|
removeWorktree(folder);
|
package/dist/panel3/presence.js
CHANGED
|
@@ -86,6 +86,13 @@ export async function sayListening(client, machineId, name, harness) {
|
|
|
86
86
|
if (error)
|
|
87
87
|
throw new Error(`could not say this machine is listening: ${error.message}`);
|
|
88
88
|
}
|
|
89
|
+
/** Only a path-free category leaves the machine. The full error stays in its log. */
|
|
90
|
+
export async function sayPollingProblem(client, machineId, harness, problem) {
|
|
91
|
+
const { error } = await client.from('panel3_machines').update({ problem })
|
|
92
|
+
.eq('machine_id', machineId).eq('harness', harness);
|
|
93
|
+
if (error)
|
|
94
|
+
throw new Error(`could not report worker health: ${error.message}`);
|
|
95
|
+
}
|
|
89
96
|
/**
|
|
90
97
|
* Stop saying it, because this daemon is going away.
|
|
91
98
|
*
|
package/dist/panel3/prompt.js
CHANGED
|
@@ -327,6 +327,8 @@ export const whatWasAttached = (attachments) => {
|
|
|
327
327
|
second place for it to drift. */
|
|
328
328
|
'A `workflow` is the process this work follows: stages, in an order it gives. The owner of this',
|
|
329
329
|
'conversation is the one who reads it, with `get_workflow`.',
|
|
330
|
+
'An `artifact` is a document on a work item. Read it with `get_artifact`, and revise it with',
|
|
331
|
+
'`update_artifact`.',
|
|
330
332
|
];
|
|
331
333
|
return [
|
|
332
334
|
...(codebases.length === 0 ? [] : [
|
|
@@ -378,6 +380,9 @@ const LANDING_LINES = {
|
|
|
378
380
|
'The product writes the question and both answers, and it performs the merge itself if they',
|
|
379
381
|
'choose to put the work back. Landing is the product\'s job and never yours: never merge and',
|
|
380
382
|
'never push.',
|
|
383
|
+
'If that approved merge needs recovery, call recover_landing(action="prepare"). Resolve and verify',
|
|
384
|
+
'the files, write_report(work_complete=true), then recover_landing(action="finish"). Approval persists;',
|
|
385
|
+
'do not ask again or request broader Git permissions. The product handles the Git updates.',
|
|
381
386
|
],
|
|
382
387
|
branch: [
|
|
383
388
|
'Finished work in this codebase stays on its branch. Nothing is pushed and nothing is merged.',
|
|
@@ -860,7 +865,18 @@ export const landingOutcomeContext = (landing) => {
|
|
|
860
865
|
...head,
|
|
861
866
|
`They chose to put this work back, and the product has merged ${landing.branch} onto `
|
|
862
867
|
+ `${landing.base} on this machine. It is done and it is not yours to do.`,
|
|
863
|
-
'
|
|
868
|
+
'Record completion with write_report(work_complete=true), then say so plainly in your reply, name the branch it went onto, and finish the card.',
|
|
869
|
+
];
|
|
870
|
+
}
|
|
871
|
+
if (landing.outcome === 'reconciling') {
|
|
872
|
+
return [
|
|
873
|
+
...head,
|
|
874
|
+
`The merge is already approved. Nothing has landed onto ${landing.base} yet.`,
|
|
875
|
+
`The product brought ${landing.base} into this card's branch ${landing.branch} for recovery; ${landing.conflicts} files are marked as conflicted.`,
|
|
876
|
+
'Resolve the files in your current working copy, preserve both the assignment and current base changes, and verify the combined result.',
|
|
877
|
+
'Use read-only Git commands to inspect it. The product owns Git metadata: do not stage, commit, merge or rebase yourself, and do not ask for wider filesystem access.',
|
|
878
|
+
'After verification, call write_report(work_complete=true), then recover_landing(action="finish"). If the base advances and new conflicts appear, resolve and verify again.',
|
|
879
|
+
'After a restart, recover_landing(action="prepare") resumes the same recovery without discarding your edits. Do not ask for the same merge approval again.',
|
|
864
880
|
];
|
|
865
881
|
}
|
|
866
882
|
if (landing.outcome === 'leave') {
|
|
@@ -881,13 +897,7 @@ export const landingOutcomeContext = (landing) => {
|
|
|
881
897
|
'offer leaves it with them.',
|
|
882
898
|
];
|
|
883
899
|
}
|
|
884
|
-
|
|
885
|
-
OBEY THAT. ═══ The card reads done the moment the owner replies, by
|
|
886
|
-
`panel3_answer`, and nothing an agent writes changes it. Telling it
|
|
887
|
-
otherwise would be a rule that can only be broken. What it can do is say
|
|
888
|
-
plainly that the work did not go back and where it still is, which is what
|
|
889
|
-
the person needs; C3 replaces this relay with a question, and a question is
|
|
890
|
-
what keeps the card open. */
|
|
900
|
+
// A non-conflict refusal still needs a truthful outcome and a concrete next action.
|
|
891
901
|
return [
|
|
892
902
|
...head,
|
|
893
903
|
'They chose to put this work back and the product could not do it:',
|
package/dist/panel3/run.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ═══ AGENT PANEL v3: the poll loop that answers a card. ═══
|
|
3
3
|
*
|
|
4
4
|
* THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may import
|
|
5
|
-
* it, WITH ONE NAMED EXCEPTION: `cli-v2/src/
|
|
5
|
+
* it, WITH ONE NAMED EXCEPTION: `cli-v2/src/presence.ts` imports `startPanel` from
|
|
6
6
|
* here and nothing else, which is how `cs start` runs the panel. It is written
|
|
7
7
|
* down in `conventions.md` and checked by `panel3-isolation.contract.test.mjs`;
|
|
8
8
|
* `startPanel`'s own comment at the foot of this file carries the reasoning.
|
|
@@ -136,12 +136,12 @@ import { readableWriteError } from '../firewall.js';
|
|
|
136
136
|
import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, OWNER_COMPLETION_RULES, } from './prompt.js';
|
|
137
137
|
import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
|
|
138
138
|
import { forgetSecrets, redactSecrets } from './secrets.js';
|
|
139
|
-
import { sayListening, stopListening } from './presence.js';
|
|
139
|
+
import { sayListening, sayPollingProblem, stopListening } from './presence.js';
|
|
140
140
|
import { selectedHarness } from './coordinator.js';
|
|
141
|
-
import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, worktreesOnThisMachine, } from './checkout.js';
|
|
141
|
+
import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, ownsReconciliation, prepareCardReconciliation, finishCardReconciliation, worktreesOnThisMachine, } from './checkout.js';
|
|
142
142
|
import { harness, startAgent } from './spawn.js';
|
|
143
143
|
import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
|
|
144
|
-
import { startToolsServer, PANEL3_IMAGES_BUCKET } from './tools.js';
|
|
144
|
+
import { startToolsServer, processActivationIsCurrent, PANEL3_IMAGES_BUCKET } from './tools.js';
|
|
145
145
|
import { listPanel3CodexOwnerHomeIds, removePanel3CodexOwnerHome, } from '../codex-home.js';
|
|
146
146
|
import { getMachineIdentity, scratchDir } from '../config.js';
|
|
147
147
|
import { listCodebases } from '../codebases.js';
|
|
@@ -242,19 +242,9 @@ export async function landingOffer(client, askId) {
|
|
|
242
242
|
return 'unclear';
|
|
243
243
|
return selected[0] === LAND ? 'land' : selected[0] === LEAVE ? 'leave' : 'unclear';
|
|
244
244
|
}
|
|
245
|
-
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* Not a tool, because a tool means an agent decides whether a yes takes effect,
|
|
250
|
-
* and an agent that finishes the card instead leaves a person who clicked yes
|
|
251
|
-
* with nothing. Not the poll's sweep, because the sweep cannot tell the agent
|
|
252
|
-
* what it did.
|
|
253
|
-
*
|
|
254
|
-
* ═══ AND A FAILURE DOES NOT END THE RUN. ═══ The agent has to be started to
|
|
255
|
-
* tell the person, so what happened is returned as the sentence it will be
|
|
256
|
-
* handed rather than written to `failed_because`.
|
|
257
|
-
*/
|
|
245
|
+
/** The initial approved landing is automatic. Conflict recovery returns here
|
|
246
|
+
* after the owner resolves and verifies the files through recover_landing.
|
|
247
|
+
* Failures are reported to the agent so the card can continue truthfully. */
|
|
258
248
|
export function landCardWork(where, outcome) {
|
|
259
249
|
const card = where.card;
|
|
260
250
|
if (outcome !== 'land')
|
|
@@ -270,22 +260,80 @@ export function landCardWork(where, outcome) {
|
|
|
270
260
|
};
|
|
271
261
|
}
|
|
272
262
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
263
|
+
if (ownsReconciliation(card)) {
|
|
264
|
+
return { outcome: 'reconciling', branch: card.branch, base: card.base, ...prepareCardReconciliation(card) };
|
|
265
|
+
}
|
|
266
|
+
// A repeated activation may recreate an already-committed working copy;
|
|
267
|
+
// in that case the commit helper has nothing left to do.
|
|
278
268
|
commitCardWork(card.folder, `${card.codebaseName}'s copy of this card on branch ${card.branch}`);
|
|
279
269
|
mergeIntoBase(card.source, card.branch, card.base, card.codebaseName);
|
|
280
270
|
return { outcome: 'landed', branch: card.branch, base: card.base };
|
|
281
271
|
}
|
|
282
272
|
catch (error) {
|
|
273
|
+
if (error?.mergeConflicts) {
|
|
274
|
+
try {
|
|
275
|
+
return { outcome: 'reconciling', branch: card.branch, base: card.base, ...prepareCardReconciliation(card) };
|
|
276
|
+
}
|
|
277
|
+
catch (recoveryError) {
|
|
278
|
+
error = recoveryError;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
283
281
|
return {
|
|
284
282
|
outcome: 'refused',
|
|
285
283
|
because: error instanceof Error ? error.message : String(error),
|
|
286
284
|
};
|
|
287
285
|
}
|
|
288
286
|
}
|
|
287
|
+
/** Recovery uses the current owner's recorded approval, never model-supplied
|
|
288
|
+
* paths or refs. Recheck it before resolving a working copy or mutating Git. */
|
|
289
|
+
export async function recoverLanding(client, machineId, runId, processToken, action) {
|
|
290
|
+
if (!await processActivationIsCurrent(client, runId, processToken)) {
|
|
291
|
+
throw new Error('This activation no longer owns the card. Nothing was merged.');
|
|
292
|
+
}
|
|
293
|
+
const runs = await returned(client.from('panel3_runs').select('card_id, machine_id, level, completion_requested_token')
|
|
294
|
+
.eq('id', runId).eq('process_token', processToken), 'read', 'the merge recovery owner');
|
|
295
|
+
const run = runs[0];
|
|
296
|
+
if (!run || run.level !== 2 || run.machine_id !== machineId) {
|
|
297
|
+
throw new Error('Merge recovery must run with this card’s conversation owner on its assigned machine. Nothing was merged.');
|
|
298
|
+
}
|
|
299
|
+
const offers = await returned(client.from('panel3_asks').select('id, run_id').eq('card_id', run.card_id)
|
|
300
|
+
.eq('offers_landing', true).order('created_at', { ascending: false }).limit(1), 'read', 'the latest merge approval');
|
|
301
|
+
const offer = offers[0];
|
|
302
|
+
if (!offer || offer.run_id !== runId || await landingOffer(client, offer.id) !== 'land') {
|
|
303
|
+
throw new Error('The latest ending choice does not approve a merge. Nothing was merged; use offer_ending after the work is verified.');
|
|
304
|
+
}
|
|
305
|
+
if (action === 'finish' && run.completion_requested_token !== processToken) {
|
|
306
|
+
throw new Error('Resolve and verify the work, then call write_report(work_complete=true) before finishing merge recovery.');
|
|
307
|
+
}
|
|
308
|
+
const activeWork = await returned(client.from('panel3_runs').select('id').eq('card_id', run.card_id).neq('id', runId)
|
|
309
|
+
.or('ended_at.is.null,pid.not.is.null'), 'read', 'other processes on this card');
|
|
310
|
+
if (activeWork.length > 0)
|
|
311
|
+
throw new Error('Wait for the other agents on this card to exit before recovering its merge. Nothing was merged.');
|
|
312
|
+
// Preparation or a failed retry is more work, not a completed assignment.
|
|
313
|
+
// Consume the completion marker so every new resolution must be verified.
|
|
314
|
+
let clearCompletion = client.from('panel3_runs')
|
|
315
|
+
.update({ completion_requested_token: null, completion_summary: null })
|
|
316
|
+
.eq('id', runId).eq('process_token', processToken).eq('state', 'running').is('ended_at', null);
|
|
317
|
+
if (action === 'finish')
|
|
318
|
+
clearCompletion = clearCompletion.eq('completion_requested_token', processToken);
|
|
319
|
+
const cleared = await returned(clearCompletion.select('id'), 'update', 'the merge recovery activation');
|
|
320
|
+
if (cleared.length !== 1 || !await processActivationIsCurrent(client, runId, processToken)) {
|
|
321
|
+
throw new Error('The card’s activation or verified work changed. Refresh the card, settle its work, and report verification again before retrying. Nothing was merged.');
|
|
322
|
+
}
|
|
323
|
+
const where = await workingDirectory(client, runId, 2, true);
|
|
324
|
+
const card = where.card;
|
|
325
|
+
if (!card || card.landing !== 'main') {
|
|
326
|
+
throw new Error('This card is not configured to merge onto its base branch. Nothing was merged.');
|
|
327
|
+
}
|
|
328
|
+
if (!await processActivationIsCurrent(client, runId, processToken)) {
|
|
329
|
+
throw new Error('This activation no longer owns the card. Nothing was merged.');
|
|
330
|
+
}
|
|
331
|
+
if (action === 'prepare') {
|
|
332
|
+
return { outcome: 'reconciling', branch: card.branch, base: card.base, ...prepareCardReconciliation(card) };
|
|
333
|
+
}
|
|
334
|
+
finishCardReconciliation(card);
|
|
335
|
+
return landCardWork(where, 'land');
|
|
336
|
+
}
|
|
289
337
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
290
338
|
/**
|
|
291
339
|
* ═══ NOTHING IN THIS FILE STAMPS A COLUMN FROM THIS MACHINE'S CLOCK ANY MORE,
|
|
@@ -2980,7 +3028,7 @@ export async function sweepFinishedWorktrees(client) {
|
|
|
2980
3028
|
export function clientReader(injected) {
|
|
2981
3029
|
return typeof injected === 'function' ? injected : () => injected;
|
|
2982
3030
|
}
|
|
2983
|
-
export async function run(args, injected, signal) {
|
|
3031
|
+
export async function run(args, injected, signal, lifecycle) {
|
|
2984
3032
|
let once = false;
|
|
2985
3033
|
for (const arg of args) {
|
|
2986
3034
|
if (arg === '--once')
|
|
@@ -3040,7 +3088,7 @@ export async function run(args, injected, signal) {
|
|
|
3040
3088
|
const child = await startChild(current(), tools, machineId, parentRunId, brief, codebase, processToken, choice);
|
|
3041
3089
|
hold(child.runId, child.settled);
|
|
3042
3090
|
return { runId: child.runId };
|
|
3043
|
-
});
|
|
3091
|
+
}, (runId, processToken, action) => recoverLanding(current(), machineId, runId, processToken, action));
|
|
3044
3092
|
out(`daemon machine ${machineId}`);
|
|
3045
3093
|
out(`tools ${tools.urlFor('<run-id>')}`);
|
|
3046
3094
|
out(once ? 'mode one poll' : `mode polling every ${POLL_INTERVAL_MS / 1000}s, Ctrl-C to stop`);
|
|
@@ -3078,6 +3126,9 @@ export async function run(args, injected, signal) {
|
|
|
3078
3126
|
}
|
|
3079
3127
|
try {
|
|
3080
3128
|
while (!signal?.aborted) {
|
|
3129
|
+
// Restart only at a poll boundary, before any claims, with no live work.
|
|
3130
|
+
if (lifecycle && !lifecycle.beforePoll(inFlight.size))
|
|
3131
|
+
break;
|
|
3081
3132
|
/* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
|
|
3082
3133
|
here throws on a network or database error, by design (constraint 7), and
|
|
3083
3134
|
until Slice 4 that threw straight out of `panel3/cli.js run` and exited the process.
|
|
@@ -3188,6 +3239,8 @@ export async function run(args, injected, signal) {
|
|
|
3188
3239
|
// rather than becoming an unhandled rejection.
|
|
3189
3240
|
hold(runId, answerCard(current(), tools, machineId, cardId, turns));
|
|
3190
3241
|
}
|
|
3242
|
+
await sayPollingProblem(current(), machineId, machineHarness, null);
|
|
3243
|
+
lifecycle?.ready();
|
|
3191
3244
|
if (once) {
|
|
3192
3245
|
/* ═══ UNTIL NOTHING IS LEFT, NOT ONCE OVER WHAT WAS THERE. ═══ A level 1
|
|
3193
3246
|
run dispatches WHILE it is being waited on, so the child appears in
|
|
@@ -3203,6 +3256,14 @@ export async function run(args, injected, signal) {
|
|
|
3203
3256
|
if (once)
|
|
3204
3257
|
throw error;
|
|
3205
3258
|
said(`this poll did not finish: ${error instanceof Error ? error.message : String(error)}`);
|
|
3259
|
+
if (listeningHarness !== null) {
|
|
3260
|
+
try {
|
|
3261
|
+
await sayPollingProblem(current(), machineId, listeningHarness, 'poll_failed');
|
|
3262
|
+
}
|
|
3263
|
+
catch (reportError) {
|
|
3264
|
+
said(`worker health could not be reported: ${reportError instanceof Error ? reportError.message : String(reportError)}`);
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3206
3267
|
}
|
|
3207
3268
|
if (!signal?.aborted)
|
|
3208
3269
|
await sleep(POLL_INTERVAL_MS);
|
|
@@ -3219,52 +3280,67 @@ export async function run(args, injected, signal) {
|
|
|
3219
3280
|
}
|
|
3220
3281
|
}
|
|
3221
3282
|
// ---------------------------------------------------------------------------
|
|
3222
|
-
/**
|
|
3223
|
-
*
|
|
3224
|
-
*
|
|
3225
|
-
*
|
|
3226
|
-
* experience must never require them to launch or authenticate multiple CLIs.
|
|
3227
|
-
* They launch the CLI with `cs start`.* Slices 1 to 3 print `cs start` on a
|
|
3228
|
-
* stranded card and, until this function existed, that command started a daemon
|
|
3229
|
-
* which polled no cards at all — the sentence named the right machine and the
|
|
3230
|
-
* wrong command, and no rewording could fix it.
|
|
3231
|
-
*
|
|
3232
|
-
* ═══ IT IS A NAMED EXCEPTION TO THE ISOLATION RULE, NOT A HOLE IN IT. ═══
|
|
3233
|
-
* `conventions.md` forbids anything outside `panel3/` importing anything inside
|
|
3234
|
-
* it, and `panel3-isolation.contract.test.mjs` enforces it. `cli-v2/src/daemon.ts`
|
|
3235
|
-
* is listed there with exactly ONE allowed specifier, `./panel3/run.js`, which is
|
|
3236
|
-
* why this is a single entry point handing back a single closure rather than
|
|
3237
|
-
* three exports the daemon would have to assemble. A second specifier fails the
|
|
3238
|
-
* suite, which is the point: the rule is enumerated, never weakened.
|
|
3239
|
-
*
|
|
3240
|
-
* ═══ THE CLIENT IS THE CALLER'S, AND THAT IS THE WHOLE OF "ONE SIGN-IN". ═══
|
|
3241
|
-
* `startPresence()` owns the client it built from the session `cs login` stored.
|
|
3242
|
-
* This must never take one of its own: two clients in one process is two refresh
|
|
3243
|
-
* loops on one rotating refresh token, both writing `session.json` through
|
|
3244
|
-
* `getClient`'s `onAuthStateChange`, which is the hazard `client.ts` describes
|
|
3245
|
-
* moved indoors.
|
|
3246
|
-
*
|
|
3247
|
-
* ═══ AND IT IS READ RATHER THAN HANDED OVER, BECAUSE PRESENCE REPLACES IT. ═══
|
|
3248
|
-
* A `SupabaseClient` is still accepted, for the harness and for any caller
|
|
3249
|
-
* holding exactly one. `cs start` passes a function instead, because presence's
|
|
3250
|
-
* heartbeat rebuilds its client when the account on disk changes, and the panel
|
|
3251
|
-
* holding the pre-rebuild one is the largest single share of the poll storm this
|
|
3252
|
-
* widening closes. `stop` reads it through the same getter for the same reason:
|
|
3253
|
-
* it is the last write this process makes, minutes or days after the getter was
|
|
3254
|
-
* handed over, and a goodbye written with a refused token leaves the card saying
|
|
3255
|
-
* an agent is working.
|
|
3256
|
-
*
|
|
3257
|
-
* ═══ IT DOES NOT BLOCK, AND IT DOES NOT TAKE THE DAEMON DOWN. ═══ The loop
|
|
3258
|
-
* never returns, so awaiting it here would hang `cs start` before it printed a
|
|
3259
|
-
* line. A throw that escapes the per-poll guard inside it is the panel stopping,
|
|
3260
|
-
* which is said on the one stream a person reads and leaves v2 presence
|
|
3261
|
-
* heartbeating rather than killing the process around it.
|
|
3262
|
-
*/
|
|
3283
|
+
/** Shared presence owns this supervisor and supplies its current authenticated
|
|
3284
|
+
* client. A stopped worker is restarted automatically. Explicit restarts happen
|
|
3285
|
+
* at an idle poll boundary and confirm only after a new worker completes a poll.
|
|
3286
|
+
* Sign-out stops the supervisor, so it cannot restart behind the user's back. */
|
|
3263
3287
|
export function startPanel(injected) {
|
|
3264
3288
|
const controller = new AbortController();
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3289
|
+
let requested = false;
|
|
3290
|
+
let restarting = null;
|
|
3291
|
+
let resolveRestart = null;
|
|
3292
|
+
let rejectRestart = null;
|
|
3293
|
+
const running = (async () => {
|
|
3294
|
+
while (!controller.signal.aborted) {
|
|
3295
|
+
try {
|
|
3296
|
+
await run([], injected, controller.signal, {
|
|
3297
|
+
beforePoll: (active) => {
|
|
3298
|
+
if (!requested)
|
|
3299
|
+
return true;
|
|
3300
|
+
requested = false;
|
|
3301
|
+
if (active > 0) {
|
|
3302
|
+
rejectRestart?.(new Error('Work is still running on this machine. Stop the affected cards before restarting the worker.'));
|
|
3303
|
+
return true;
|
|
3304
|
+
}
|
|
3305
|
+
return false;
|
|
3306
|
+
},
|
|
3307
|
+
ready: () => { if (!requested)
|
|
3308
|
+
resolveRestart?.(); },
|
|
3309
|
+
});
|
|
3310
|
+
}
|
|
3311
|
+
catch (error) {
|
|
3312
|
+
said(`the agent panel stopped polling: ${error instanceof Error ? error.message : String(error)}`);
|
|
3313
|
+
// Startup failures must not leave an online machine with a dead worker.
|
|
3314
|
+
if (!controller.signal.aborted)
|
|
3315
|
+
await sleep(POLL_INTERVAL_MS);
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
})();
|
|
3319
|
+
return {
|
|
3320
|
+
restart: () => {
|
|
3321
|
+
if (controller.signal.aborted)
|
|
3322
|
+
return Promise.reject(new Error('This machine is signing out. Open Companion and sign in again.'));
|
|
3323
|
+
if (restarting)
|
|
3324
|
+
return restarting;
|
|
3325
|
+
requested = true;
|
|
3326
|
+
let timeout;
|
|
3327
|
+
restarting = new Promise((resolve, reject) => {
|
|
3328
|
+
resolveRestart = resolve;
|
|
3329
|
+
rejectRestart = reject;
|
|
3330
|
+
timeout = setTimeout(() => reject(new Error('The worker did not reconnect. Open Companion on this machine, then try again.')), 20_000);
|
|
3331
|
+
}).finally(() => {
|
|
3332
|
+
clearTimeout(timeout);
|
|
3333
|
+
requested = false;
|
|
3334
|
+
restarting = null;
|
|
3335
|
+
resolveRestart = null;
|
|
3336
|
+
rejectRestart = null;
|
|
3337
|
+
});
|
|
3338
|
+
return restarting;
|
|
3339
|
+
},
|
|
3340
|
+
stop: async () => {
|
|
3341
|
+
controller.abort();
|
|
3342
|
+
rejectRestart?.(new Error('The machine disconnected before the worker restarted.'));
|
|
3343
|
+
await running;
|
|
3344
|
+
},
|
|
3345
|
+
};
|
|
3270
3346
|
}
|
package/dist/panel3/tools.js
CHANGED
|
@@ -126,7 +126,7 @@ import { z } from 'zod';
|
|
|
126
126
|
import { returned } from './client.js';
|
|
127
127
|
import { readableWriteError, FIREWALL_WRITING_RULE } from '../firewall.js';
|
|
128
128
|
import { rememberSecret, redactArgs } from './secrets.js';
|
|
129
|
-
import { workBrief } from './prompt.js';
|
|
129
|
+
import { workBrief, landingOutcomeContext } from './prompt.js';
|
|
130
130
|
import { listCodexModels } from './codex-models.js';
|
|
131
131
|
/* The harness this daemon spawns with, which is the process this server runs
|
|
132
132
|
in. It is what `panel3_runs.harness` is written from at spawn, so reading it
|
|
@@ -2030,6 +2030,26 @@ const TOOLS = [
|
|
|
2030
2030
|
+ 'Nobody will be asked again.';
|
|
2031
2031
|
},
|
|
2032
2032
|
},
|
|
2033
|
+
{
|
|
2034
|
+
name: 'recover_landing',
|
|
2035
|
+
levels: [2],
|
|
2036
|
+
description: 'Recover a merge the person already approved. The product manages Git metadata; you resolve '
|
|
2037
|
+
+ 'and verify ordinary files in this card’s working copy. Use prepare to bring the current base '
|
|
2038
|
+
+ 'into the card branch, including after a restart or a Git permission failure. Resolve conflicts '
|
|
2039
|
+
+ 'and run checks, record write_report(work_complete=true), then use finish to retry landing. '
|
|
2040
|
+
+ 'No new approval is needed. Never change the sandbox or ask the person to reopen with Git access. '
|
|
2041
|
+
+ 'After landing succeeds, record completion again and reply. If recovery fails, report the exact remaining action.',
|
|
2042
|
+
input: { action: z.enum(['prepare', 'finish']) },
|
|
2043
|
+
handler: async (caller, args) => {
|
|
2044
|
+
if (!caller.isOwner || caller.level !== 2 || !caller.processToken) {
|
|
2045
|
+
throw new Error('Only the current conversation owner can recover its approved merge.');
|
|
2046
|
+
}
|
|
2047
|
+
if (!caller.recoverLanding)
|
|
2048
|
+
throw new Error('Merge recovery is unavailable on this companion. Update and restart the companion on this card’s machine, then retry in this card.');
|
|
2049
|
+
const { action } = args;
|
|
2050
|
+
return landingOutcomeContext(await caller.recoverLanding(caller.runId, caller.processToken, action)).join('\n');
|
|
2051
|
+
},
|
|
2052
|
+
},
|
|
2033
2053
|
/**
|
|
2034
2054
|
* ═══ THE AGENT ASKS FOR THE OFFER; THE PRODUCT WRITES IT. ═══
|
|
2035
2055
|
*
|
|
@@ -2051,7 +2071,8 @@ const TOOLS = [
|
|
|
2051
2071
|
levels: [2],
|
|
2052
2072
|
description: 'Offer the person the ending for this card: put the finished work onto the main branch, or '
|
|
2053
2073
|
+ 'leave it on its branch. Call this when the card\'s work is DONE and the codebase lands on '
|
|
2054
|
-
+ 'the main branch.
|
|
2074
|
+
+ 'the main branch. If the person already approved a merge that needs recovery, use recover_landing instead of asking again. '
|
|
2075
|
+
+ 'First record verified completion with write_report(work_complete=true). '
|
|
2055
2076
|
+ 'Use say for progress; this tool cannot announce work you intend to do. You do not write the question or the answers and you never merge anything: '
|
|
2056
2077
|
+ 'the product composes both, and it performs the merge itself if they choose to put the work '
|
|
2057
2078
|
+ 'back. Say in one sentence what was done, in their words. After this call, stop immediately: '
|
|
@@ -2824,7 +2845,7 @@ const TOOLS = [
|
|
|
2824
2845
|
await whileRunning(client.rpc('panel3_request_completion', {
|
|
2825
2846
|
p_run_id: runId, p_process_token: processToken, p_summary: report,
|
|
2826
2847
|
}), runId, 'declare completion of');
|
|
2827
|
-
return 'Completion recorded. If the finished work needs
|
|
2848
|
+
return 'Completion recorded. If an already-approved merge is awaiting recovery, call recover_landing(action="finish"); if the finished work needs its first ending choice, call offer_ending; otherwise give your final answer and exit. Done waits until all processes have exited.';
|
|
2828
2849
|
}
|
|
2829
2850
|
await whileRunning(processToken === undefined
|
|
2830
2851
|
? client.from('panel3_runs').update({ report }).eq('id', runId)
|
|
@@ -3341,6 +3362,8 @@ export function toolShape(name) {
|
|
|
3341
3362
|
function toolAvailable(tool, level, isOwner) {
|
|
3342
3363
|
if (isOwner && tool.name === 'escalate')
|
|
3343
3364
|
return false;
|
|
3365
|
+
if (tool.name === 'recover_landing' && !isOwner)
|
|
3366
|
+
return false;
|
|
3344
3367
|
return tool.levels.includes(level) || (level === 2 && isOwner && workflowToolForName(tool.name) !== undefined);
|
|
3345
3368
|
}
|
|
3346
3369
|
function toolNamed(name) {
|
|
@@ -3431,7 +3454,7 @@ export async function processActivationIsCurrent(client, runId, processToken) {
|
|
|
3431
3454
|
* below goes through the signed-in user's own token, so guessing a run id would
|
|
3432
3455
|
* still only ever reach that user's own record.
|
|
3433
3456
|
*/
|
|
3434
|
-
export async function startToolsServer(client, dispatch) {
|
|
3457
|
+
export async function startToolsServer(client, dispatch, recoverLanding) {
|
|
3435
3458
|
const { data, error } = await client.auth.getUser();
|
|
3436
3459
|
if (error)
|
|
3437
3460
|
throw new Error(`could not start the tools server: ${error.message}`);
|
|
@@ -3550,7 +3573,7 @@ export async function startToolsServer(client, dispatch) {
|
|
|
3550
3573
|
};
|
|
3551
3574
|
const server = buildServer({
|
|
3552
3575
|
client, userId, runId, cardId: run.card_id, level: run.level,
|
|
3553
|
-
processToken, isOwner, dispatch,
|
|
3576
|
+
processToken, isOwner, dispatch, recoverLanding,
|
|
3554
3577
|
});
|
|
3555
3578
|
await server.connect(transport);
|
|
3556
3579
|
await transport.handleRequest(req, res, body);
|
package/dist/presence.js
CHANGED
|
@@ -9,6 +9,8 @@ import { buildPresenceHeartbeatPayload } from './presence-heartbeat.js';
|
|
|
9
9
|
import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, takeRunMessages, liveAgents, } from './orchestrator.js';
|
|
10
10
|
/* 18c Slice 8 — the on-disk half of the crash recovery beside it. */
|
|
11
11
|
import { sweepStrandedCodexHomes } from './codex-home.js';
|
|
12
|
+
import { claimDaemonLock, releaseDaemonLock } from './daemon-lock.js';
|
|
13
|
+
import { startPanel } from './panel3/run.js';
|
|
12
14
|
let presence = null;
|
|
13
15
|
/** In-flight guard: startPresence yields to the event loop (network setSession)
|
|
14
16
|
* before `presence` is assigned, so a plain `if (presence)` check lets two
|
|
@@ -246,21 +248,57 @@ async function cleanupSupersededRows(p) {
|
|
|
246
248
|
}
|
|
247
249
|
}
|
|
248
250
|
async function pollCommands(p) {
|
|
251
|
+
if (p.pollingCommands)
|
|
252
|
+
return;
|
|
253
|
+
p.pollingCommands = true;
|
|
249
254
|
try {
|
|
250
255
|
const { data, error } = await p.client
|
|
251
256
|
.from('cliv2_commands')
|
|
252
257
|
.update({ status: 'ack', acked_at: new Date().toISOString() })
|
|
253
258
|
.eq('machine_id', p.identity.id)
|
|
254
259
|
.eq('status', 'pending')
|
|
260
|
+
.eq('command', 'ping')
|
|
255
261
|
.select('id, command');
|
|
256
262
|
if (error)
|
|
257
263
|
throw error;
|
|
258
264
|
for (const cmd of data ?? [])
|
|
259
265
|
console.log(`Acked ${cmd.command} (${cmd.id})`);
|
|
266
|
+
const { data: restarts, error: restartError } = await p.client
|
|
267
|
+
.from('cliv2_commands')
|
|
268
|
+
.update({ status: 'processing' })
|
|
269
|
+
.eq('machine_id', p.identity.id)
|
|
270
|
+
.eq('status', 'pending')
|
|
271
|
+
.eq('command', 'restart_worker')
|
|
272
|
+
.select('id, created_at');
|
|
273
|
+
if (restartError)
|
|
274
|
+
throw restartError;
|
|
275
|
+
for (const command of restarts ?? []) {
|
|
276
|
+
let result = 'Worker restarted';
|
|
277
|
+
let status = 'ack';
|
|
278
|
+
try {
|
|
279
|
+
if (Date.now() - Date.parse(command.created_at) > 30_000) {
|
|
280
|
+
throw new Error('This restart request expired. Try again while the machine is online.');
|
|
281
|
+
}
|
|
282
|
+
if (!p.panel)
|
|
283
|
+
throw new Error('The worker has not started. Open Companion on this machine and reconnect.');
|
|
284
|
+
await p.panel.restart();
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
status = 'failed';
|
|
288
|
+
result = error instanceof Error ? error.message : String(error);
|
|
289
|
+
}
|
|
290
|
+
const { error: saved } = await p.client.from('cliv2_commands')
|
|
291
|
+
.update({ status, result, acked_at: new Date().toISOString() }).eq('id', command.id);
|
|
292
|
+
if (saved)
|
|
293
|
+
throw saved;
|
|
294
|
+
}
|
|
260
295
|
}
|
|
261
296
|
catch (err) {
|
|
262
297
|
console.warn(`command poll failed, will retry: ${err.message}`);
|
|
263
298
|
}
|
|
299
|
+
finally {
|
|
300
|
+
p.pollingCommands = false;
|
|
301
|
+
}
|
|
264
302
|
}
|
|
265
303
|
/**
|
|
266
304
|
* One turn of the orchestrator listener (feature 16, Slice 3). All the logic is
|
|
@@ -298,12 +336,17 @@ async function pollOrchestrator(p) {
|
|
|
298
336
|
* the companion guard for that; the terminal daemon lets it surface. No-op if
|
|
299
337
|
* already running. */
|
|
300
338
|
export async function startPresence() {
|
|
339
|
+
if (stopping)
|
|
340
|
+
await stopping;
|
|
301
341
|
if (presence) {
|
|
302
342
|
return { machineName: presence.identity.name, agents: presence.agents };
|
|
303
343
|
}
|
|
304
344
|
if (starting)
|
|
305
345
|
return starting;
|
|
306
346
|
starting = (async () => {
|
|
347
|
+
const lock = claimDaemonLock();
|
|
348
|
+
if (lock.held)
|
|
349
|
+
throw new Error(`CTRL+SPC is already running on this computer (pid ${lock.pid}).`);
|
|
307
350
|
const identity = getMachineIdentity();
|
|
308
351
|
const agents = detectAgents();
|
|
309
352
|
const client = await getClient();
|
|
@@ -337,6 +380,7 @@ export async function startPresence() {
|
|
|
337
380
|
throw new Error('Signed-in user could not be resolved. Sign in again.');
|
|
338
381
|
const p = {
|
|
339
382
|
client,
|
|
383
|
+
panel: null,
|
|
340
384
|
userId,
|
|
341
385
|
identity,
|
|
342
386
|
agents,
|
|
@@ -482,11 +526,19 @@ export async function startPresence() {
|
|
|
482
526
|
catch (err) {
|
|
483
527
|
console.warn(`Agent tools server did not start: ${err.message}`);
|
|
484
528
|
}
|
|
529
|
+
// Both cs open and cs start answer cards through this one lifecycle.
|
|
530
|
+
// Read the owner's client on every use, including after refresh or logout.
|
|
531
|
+
p.panel = startPanel(() => p.client);
|
|
485
532
|
return { machineName: identity.name, agents };
|
|
486
533
|
})();
|
|
487
534
|
try {
|
|
488
535
|
return await starting;
|
|
489
536
|
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
await stopPresence();
|
|
539
|
+
releaseDaemonLock();
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
490
542
|
finally {
|
|
491
543
|
starting = null;
|
|
492
544
|
}
|
|
@@ -535,6 +587,7 @@ export async function stopPresence({ unregister = false } = {}) {
|
|
|
535
587
|
call `stopPresence()` and kill the presence belonging to a DIFFERENT
|
|
536
588
|
session, one that is signed in and healthy. */
|
|
537
589
|
p.authSubscription.unsubscribe();
|
|
590
|
+
await p.panel?.stop();
|
|
538
591
|
// On logout (unregister), scrub the ctrl-spc entry from each detected agent's
|
|
539
592
|
// config so a logged-out machine leaves no dead server that would read "failed
|
|
540
593
|
// to connect" on the next agent run. Fire-and-forget best-effort — never blocks
|
|
@@ -561,5 +614,6 @@ export async function stopPresence({ unregister = false } = {}) {
|
|
|
561
614
|
}
|
|
562
615
|
finally {
|
|
563
616
|
stopping = null;
|
|
617
|
+
releaseDaemonLock();
|
|
564
618
|
}
|
|
565
619
|
}
|
package/package.json
CHANGED