toolroll 0.9.10 → 0.9.12
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/CHANGELOG.md +25 -0
- package/dist/assignment.d.ts +3 -0
- package/dist/assignment.js +3 -1
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +20 -18
- package/dist/browser-workspace.d.ts +61 -0
- package/dist/chat-channel.d.ts +9 -7
- package/dist/chat-channel.js +23 -17
- package/dist/chat-delivery.js +21 -0
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +3 -1
- package/dist/chat-voice.js +13 -5
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/coding-update.d.ts +13 -0
- package/dist/coding-update.js +68 -0
- package/dist/desktop-update.d.ts +7 -0
- package/dist/desktop-update.js +42 -1
- package/dist/flow-triggers.js +17 -1
- package/dist/guides.js +9 -0
- package/dist/lead-status.d.ts +10 -1
- package/dist/lead-status.js +13 -3
- package/dist/lead-voice.d.ts +81 -0
- package/dist/lead-voice.js +275 -0
- package/dist/needs-you.d.ts +7 -0
- package/dist/needs-you.js +9 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +186 -38
- package/dist/serve.d.ts +5 -1
- package/dist/serve.js +447 -226
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +15 -1
- package/dist/store.js +38 -1
- package/dist/surface.js +8 -1
- package/dist/task-status.d.ts +6 -0
- package/dist/task-status.js +19 -2
- package/dist/team-cli.js +1 -1
- package/dist/telegram-mate.d.ts +7 -0
- package/dist/telegram-mate.js +50 -21
- package/dist/telegram-progress.js +7 -0
- package/dist/telegram-settings.d.ts +3 -0
- package/dist/telegram-settings.js +11 -0
- package/dist/telegram.d.ts +14 -0
- package/dist/telegram.js +105 -12
- package/dist/test-temp.js +1 -1
- package/dist/toolroll-update.d.ts +3 -0
- package/dist/toolroll-update.js +13 -2
- package/dist/work-index.d.ts +9 -0
- package/dist/work-index.js +57 -20
- package/dist/work-summary.d.ts +4 -1
- package/dist/workspace-ui.d.ts +25 -0
- package/dist/workspace-ui.js +20 -1
- package/package.json +1 -1
|
@@ -7,6 +7,8 @@ export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
|
|
|
7
7
|
import type { Store } from './store.js';
|
|
8
8
|
import type { WorkSummaryAccess } from './work-summary.js';
|
|
9
9
|
import type { StatusTone } from './workspace-ui.js';
|
|
10
|
+
import type { Ask } from './needs-you.js';
|
|
11
|
+
import type { WorkIndexGroup } from './work-index.js';
|
|
10
12
|
import type { AssignmentCard } from './assignment-ui.js';
|
|
11
13
|
import type { TaskStatus } from './task-status.js';
|
|
12
14
|
import type { FirstRunStep, FirstTaskSuggestion, JourneyStep } from './first-run.js';
|
|
@@ -103,6 +105,14 @@ export type BrowserTasksView = {
|
|
|
103
105
|
count: number;
|
|
104
106
|
active: boolean;
|
|
105
107
|
})[];
|
|
108
|
+
/** Tasks waiting on a person in this view's scope ("3 need you" beside the title). */
|
|
109
|
+
needsYou: number;
|
|
110
|
+
/** All and Needs you list their rows in these groups, in this order (empty ones left out); null: one plain list. */
|
|
111
|
+
groups: {
|
|
112
|
+
key: BrowserTaskGroup;
|
|
113
|
+
label: string;
|
|
114
|
+
count: number;
|
|
115
|
+
}[] | null;
|
|
106
116
|
rows: {
|
|
107
117
|
id: string;
|
|
108
118
|
title: string;
|
|
@@ -114,6 +124,9 @@ export type BrowserTasksView = {
|
|
|
114
124
|
tone: StatusTone;
|
|
115
125
|
token: string;
|
|
116
126
|
};
|
|
127
|
+
/** What a Needs you row asks (its chip); null for every other row. */
|
|
128
|
+
ask: Ask | null;
|
|
129
|
+
group: BrowserTaskGroup;
|
|
117
130
|
action: BrowserLink | null;
|
|
118
131
|
detail: string | null;
|
|
119
132
|
problem: string | null;
|
|
@@ -133,6 +146,7 @@ export type BrowserTasksView = {
|
|
|
133
146
|
limits: BrowserLimits | null;
|
|
134
147
|
};
|
|
135
148
|
/** One limit: a plan's usage window ("Claude · 5-hour, 48%") or a monthly budget ("shop · Budget, $4.20 of $10"). */
|
|
149
|
+
export type BrowserTaskGroup = WorkIndexGroup;
|
|
136
150
|
export type BrowserLimitTile = {
|
|
137
151
|
key: string;
|
|
138
152
|
name: string;
|
|
@@ -436,6 +450,7 @@ export type BrowserResultPanel = {
|
|
|
436
450
|
* Accept that records the decision (null when it is not offered here). */
|
|
437
451
|
youCheck: {
|
|
438
452
|
lines: string[];
|
|
453
|
+
items: BrowserCheckItem[];
|
|
439
454
|
accept: {
|
|
440
455
|
action: string;
|
|
441
456
|
run: number;
|
|
@@ -465,6 +480,39 @@ export type BrowserResultPanel = {
|
|
|
465
480
|
/** The feedback section holds only the closed form (no notes, revisions or history). */
|
|
466
481
|
requestQuiet: boolean;
|
|
467
482
|
};
|
|
483
|
+
/** One "You check this one" item and the evidence to judge it by: the
|
|
484
|
+
* changed lines it cites (or, citing none, the change's own first lines),
|
|
485
|
+
* its screenshots, and the agent's note. Wrapped text, never sideways. */
|
|
486
|
+
export type BrowserCheckItem = {
|
|
487
|
+
id: string;
|
|
488
|
+
statement: string;
|
|
489
|
+
words: string;
|
|
490
|
+
note: string | null;
|
|
491
|
+
excerpts: {
|
|
492
|
+
path: string;
|
|
493
|
+
cited: boolean;
|
|
494
|
+
lines: {
|
|
495
|
+
kind: "addition" | "deletion" | "context";
|
|
496
|
+
line: number | null;
|
|
497
|
+
text: string;
|
|
498
|
+
}[];
|
|
499
|
+
more: number;
|
|
500
|
+
}[];
|
|
501
|
+
shots: {
|
|
502
|
+
src: string;
|
|
503
|
+
href: string;
|
|
504
|
+
caption: string;
|
|
505
|
+
}[];
|
|
506
|
+
};
|
|
507
|
+
/** The result's one decision: what Accept is called, why it isn't plain
|
|
508
|
+
* Accept, and what pressing it does. */
|
|
509
|
+
export type BrowserResultDecision = {
|
|
510
|
+
label: "Accept" | "Accept without checks";
|
|
511
|
+
ready: boolean;
|
|
512
|
+
why: string | null;
|
|
513
|
+
effect: string;
|
|
514
|
+
sentence: string;
|
|
515
|
+
};
|
|
468
516
|
/** A Needs you action: a link, (Confirm it stopped) a form behind the password, (Build again) one
|
|
469
517
|
* button, or (on the result itself) Accept, which records the person's acceptance; `note` asks why
|
|
470
518
|
* when the evidence disagrees. */
|
|
@@ -531,6 +579,8 @@ export type BrowserResultView = {
|
|
|
531
579
|
receipt: string;
|
|
532
580
|
run: number;
|
|
533
581
|
} | null;
|
|
582
|
+
/** Words for the result's Accept: Mark complete when offered, else the Needs you acceptance. */
|
|
583
|
+
decision: BrowserResultDecision | null;
|
|
534
584
|
checks: {
|
|
535
585
|
detail: string;
|
|
536
586
|
problem: boolean;
|
|
@@ -889,6 +939,11 @@ export type BrowserWorkspace = {
|
|
|
889
939
|
refreshSeconds?: number;
|
|
890
940
|
/** Providers whose sign-in stopped working: their work waits (one per provider). */
|
|
891
941
|
signIn?: BrowserSignIn[];
|
|
942
|
+
/** A demo database: the notice that says so, scrolling with the page (`short`: its one line on a phone). */
|
|
943
|
+
demo?: {
|
|
944
|
+
text: string;
|
|
945
|
+
short: string;
|
|
946
|
+
};
|
|
892
947
|
/** A newer Toolroll exists: a quiet notice for an operator, until they dismiss this version. */
|
|
893
948
|
update?: BrowserUpdateNotice;
|
|
894
949
|
/** Chat's first run, until the first Ready result: the three steps and first tasks to try. */
|
|
@@ -945,6 +1000,12 @@ export type BrowserHome = {
|
|
|
945
1000
|
}[];
|
|
946
1001
|
catchUp: BrowserCatchUpItem[];
|
|
947
1002
|
allHref: string;
|
|
1003
|
+
/** What this person's lead is doing now and when it last acted (lead-voice.ts); its task when it named one. */
|
|
1004
|
+
lead?: {
|
|
1005
|
+
doing: string;
|
|
1006
|
+
at: string;
|
|
1007
|
+
href: string | null;
|
|
1008
|
+
} | null;
|
|
948
1009
|
};
|
|
949
1010
|
/** `sandbox`: the demo command, offered beside the sign-in command while no agent is signed in. `intro`: how it works,
|
|
950
1011
|
* once, above the composer with the suggestions. `lead`: the one line saying what runs the lead, and where to change it.
|
package/dist/chat-channel.d.ts
CHANGED
|
@@ -12,18 +12,18 @@ export type ResolvedMate = {
|
|
|
12
12
|
};
|
|
13
13
|
} | {
|
|
14
14
|
ok: false;
|
|
15
|
-
reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "
|
|
15
|
+
reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "busy" | "no-session";
|
|
16
16
|
said: string | null;
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* The session and thread a paired phone speaks in: the approver's live
|
|
20
20
|
* session when it is compatible (same generation, same membership
|
|
21
|
-
* credential, same ceiling), else one minted under the very terms
|
|
22
|
-
* console and CLI state for membership chat (no dollar ceiling, this
|
|
23
|
-
* ceiling digest)
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
21
|
+
* credential, same ceiling), else a fresh one minted under the very terms
|
|
22
|
+
* the console and CLI state for membership chat (no dollar ceiling, this
|
|
23
|
+
* ceiling digest). An incompatible session or thread left open on the
|
|
24
|
+
* computer is replaced rather than refused, so a message from the phone
|
|
25
|
+
* always gets an answer — except while a turn is still running there,
|
|
26
|
+
* which is "busy": the message waits its turn instead of ending it.
|
|
27
27
|
*/
|
|
28
28
|
export declare function resolveChannelMate(store: Store, binding: {
|
|
29
29
|
approver: string;
|
|
@@ -34,6 +34,8 @@ export declare function resolveChannelMate(store: Store, binding: {
|
|
|
34
34
|
* chat as well, so the task shows everything asked of it wherever it was
|
|
35
35
|
* asked. Best effort: the phone conversation never depends on it. */
|
|
36
36
|
export declare function mirrorToTaskChat(store: Store, who: VerifiedApprover, taskId: string, surface: string, message: string, reply: string, now: Date): void;
|
|
37
|
+
/** The one short message a person gets when their question could not be answered. */
|
|
38
|
+
export declare function couldNotAnswerText(reason: string): string;
|
|
37
39
|
export declare function tooLongText(length: number): string;
|
|
38
40
|
/** A reply to a message about several tasks is never guessed: it names them and says how to choose. */
|
|
39
41
|
export declare function whichTaskText(titles: readonly string[]): string;
|
package/dist/chat-channel.js
CHANGED
|
@@ -25,12 +25,12 @@ export function channelRepos(store, approver, registry) {
|
|
|
25
25
|
/**
|
|
26
26
|
* The session and thread a paired phone speaks in: the approver's live
|
|
27
27
|
* session when it is compatible (same generation, same membership
|
|
28
|
-
* credential, same ceiling), else one minted under the very terms
|
|
29
|
-
* console and CLI state for membership chat (no dollar ceiling, this
|
|
30
|
-
* ceiling digest)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* credential, same ceiling), else a fresh one minted under the very terms
|
|
29
|
+
* the console and CLI state for membership chat (no dollar ceiling, this
|
|
30
|
+
* ceiling digest). An incompatible session or thread left open on the
|
|
31
|
+
* computer is replaced rather than refused, so a message from the phone
|
|
32
|
+
* always gets an answer — except while a turn is still running there,
|
|
33
|
+
* which is "busy": the message waits its turn instead of ending it.
|
|
34
34
|
*/
|
|
35
35
|
export function resolveChannelMate(store, binding, repos, now) {
|
|
36
36
|
const config = store.getChatConfig();
|
|
@@ -60,18 +60,17 @@ export function resolveChannelMate(store, binding, repos, now) {
|
|
|
60
60
|
return { ok: false, reason: "unpaired", said: null };
|
|
61
61
|
const who = verified.who;
|
|
62
62
|
const credentialKey = subscriptionCredentialKey(config.provider);
|
|
63
|
-
const mismatch = "Your open chat session on the computer covers different projects or another provider. Finish or end it there, then message here again. Nothing was changed.";
|
|
64
63
|
let session = store.activeMateSession(who.name);
|
|
65
|
-
|
|
64
|
+
const liveThread = store.liveMateThreadFor(who.name);
|
|
65
|
+
const sessionMismatch = session !== null &&
|
|
66
66
|
(session.approverGeneration !== who.generation ||
|
|
67
67
|
session.credentialKey !== credentialKey ||
|
|
68
|
-
session.ceilingDigest !== who.ceilingDigest)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (session === null) {
|
|
68
|
+
session.ceilingDigest !== who.ceilingDigest);
|
|
69
|
+
const threadMismatch = liveThread !== null && liveThread.ceilingDigest !== who.ceilingDigest;
|
|
70
|
+
// Replacing either would end a turn still running on the computer.
|
|
71
|
+
if ((sessionMismatch || threadMismatch) && store.liveMateTurnFor(who.name) !== null)
|
|
72
|
+
return { ok: false, reason: "busy", said: null };
|
|
73
|
+
if (session === null || sessionMismatch) {
|
|
75
74
|
const termsDigest = createHash("sha256")
|
|
76
75
|
.update(`0\n${who.ceilingDigest}`)
|
|
77
76
|
.digest("hex");
|
|
@@ -85,9 +84,12 @@ export function resolveChannelMate(store, binding, repos, now) {
|
|
|
85
84
|
}, now);
|
|
86
85
|
session = store.getMateSession(id);
|
|
87
86
|
if (session === null)
|
|
88
|
-
return { ok: false, reason: "session
|
|
87
|
+
return { ok: false, reason: "no-session", said: couldNotAnswerText("a new chat session couldn't be started") };
|
|
89
88
|
}
|
|
90
|
-
|
|
89
|
+
// openMateThread closes a thread kept under another ceiling.
|
|
90
|
+
const thread = threadMismatch || liveThread === null
|
|
91
|
+
? store.openMateThread(who.name, who.ceilingDigest, now).thread
|
|
92
|
+
: liveThread;
|
|
91
93
|
return {
|
|
92
94
|
ok: true,
|
|
93
95
|
who,
|
|
@@ -113,6 +115,10 @@ export function mirrorToTaskChat(store, who, taskId, surface, message, reply, no
|
|
|
113
115
|
// The task chat is a copy; the phone's own conversation already has it.
|
|
114
116
|
}
|
|
115
117
|
}
|
|
118
|
+
/** The one short message a person gets when their question could not be answered. */
|
|
119
|
+
export function couldNotAnswerText(reason) {
|
|
120
|
+
return `I couldn't answer that just now: ${reason}. Ask again, or open the console.`;
|
|
121
|
+
}
|
|
116
122
|
export function tooLongText(length) {
|
|
117
123
|
return `That message is ${length.toLocaleString("en-US")} characters; chat takes up to ${MATE_MESSAGE_MAX_CHARS.toLocaleString("en-US")}. Nothing was sent to the assistant. Send it in shorter parts, or say which part matters most.`;
|
|
118
124
|
}
|
package/dist/chat-delivery.js
CHANGED
|
@@ -11,6 +11,7 @@ import { triggerConfigOf } from "./flow-triggers.js";
|
|
|
11
11
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
12
12
|
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
13
13
|
import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
|
|
14
|
+
import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
|
|
14
15
|
import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
|
|
15
16
|
import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
|
|
16
17
|
import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
|
|
@@ -202,6 +203,10 @@ export async function processChatEvent(options) {
|
|
|
202
203
|
return true;
|
|
203
204
|
}
|
|
204
205
|
const resolved = resolveChannelMate(store, { approver: binding.approver, approverGeneration: binding.generation }, repos, now);
|
|
206
|
+
if (!resolved.ok && resolved.reason === "busy") {
|
|
207
|
+
state.defer(event.id, "Assistant is answering another message", new Date(now.getTime() + 5000));
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
205
210
|
if (!resolved.ok) {
|
|
206
211
|
state.plan(event.id, [
|
|
207
212
|
{
|
|
@@ -647,6 +652,7 @@ export async function planChatNotifications(options) {
|
|
|
647
652
|
enqueueEveningDigests(store, nowOf(options), options.evidenceRoot);
|
|
648
653
|
}
|
|
649
654
|
catch { /* Retried on the next pass. */ }
|
|
655
|
+
enqueueLeadLapses(store, nowOf(options));
|
|
650
656
|
const cursor = Number(state
|
|
651
657
|
.prepare("SELECT notification FROM chat_runtime WHERE installation=?")
|
|
652
658
|
.get(identity.installation)?.notification ?? 0);
|
|
@@ -672,6 +678,21 @@ export async function planChatNotifications(options) {
|
|
|
672
678
|
// Only when I'm needed (the default): the task's one card is edited in place, and a new message
|
|
673
679
|
// follows only when this person is needed. Every step keeps the branches below unchanged.
|
|
674
680
|
const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
|
|
681
|
+
if (notification.kind === LEAD_SAY_KIND) {
|
|
682
|
+
// The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
|
|
683
|
+
const content = { text: leadSayText(notification),
|
|
684
|
+
...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
|
|
685
|
+
const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE event=?")
|
|
686
|
+
.get(chatHash(`${options.state.channel}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
|
|
687
|
+
if (earlier !== undefined) {
|
|
688
|
+
state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(JSON.stringify(content), now.toISOString(), Number(earlier.id));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
692
|
+
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
693
|
+
state.plan(id, [content], now);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
675
696
|
if (quiet) {
|
|
676
697
|
planQuietCard(options, binding, notification, now);
|
|
677
698
|
if (!needsPerson(notification))
|
package/dist/chat-quiet.js
CHANGED
|
@@ -149,6 +149,7 @@ function lineOf(store, item, now, root, viewer) {
|
|
|
149
149
|
pullRequest,
|
|
150
150
|
// The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
|
|
151
151
|
lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
|
|
152
|
+
...(card.facts.lead === "on-it" ? { leadOnIt: true } : {}),
|
|
152
153
|
};
|
|
153
154
|
// A result ready for a person offers its real next step, then a look first.
|
|
154
155
|
if (asksToFinish(fact))
|
package/dist/chat-voice.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export type FinishedFact = {
|
|
|
34
34
|
words: string;
|
|
35
35
|
phrase: string;
|
|
36
36
|
} | null;
|
|
37
|
+
/** The person's lead took it on (lead-voice.ts): "Your lead is on it", never "It waits for you". */
|
|
38
|
+
leadOnIt?: boolean;
|
|
37
39
|
};
|
|
38
40
|
/** The two buttons under a result ready for a person: the real next step, then a look first. */
|
|
39
41
|
export declare const READY_ACTIONS: {
|
|
@@ -53,7 +55,7 @@ export declare function batchLine(facts: readonly FinishedFact[]): string;
|
|
|
53
55
|
* StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
|
|
54
56
|
* leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
|
|
55
57
|
*/
|
|
56
|
-
export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade"): Promise<boolean>;
|
|
58
|
+
export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade", signal?: AbortSignal): Promise<boolean>;
|
|
57
59
|
/** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
|
|
58
60
|
* "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
|
|
59
61
|
* task's short title when one is given, else "this task". */
|
package/dist/chat-voice.js
CHANGED
|
@@ -53,6 +53,9 @@ export function finishedLine(fact) {
|
|
|
53
53
|
return fact.update.words;
|
|
54
54
|
const name = fact.summary, lower = inSentence(fact.summary);
|
|
55
55
|
const ready = fact.lead === true ? `I finished ${lower}` : `${name} is ready`;
|
|
56
|
+
// What waited on the person and the lead has taken on (Needs you reads Waiting then).
|
|
57
|
+
if (fact.leadOnIt === true && (fact.headline === "Needs you" || fact.headline === "Waiting" || fact.headline === "Stopped"))
|
|
58
|
+
return `${name}: your lead is on it.`;
|
|
56
59
|
switch (fact.headline) {
|
|
57
60
|
case "Ready for review":
|
|
58
61
|
if (fact.report)
|
|
@@ -63,6 +66,8 @@ export function finishedLine(fact) {
|
|
|
63
66
|
return `${ready}. Checks are off for this project, so look it over first.`;
|
|
64
67
|
return `${ready}, but no tests ran. Look it over first.`;
|
|
65
68
|
case "Failed":
|
|
69
|
+
if (fact.leadOnIt === true)
|
|
70
|
+
return fact.checks === "failed" ? `${name} is built, but its tests failed. Your lead is on it.` : `${name} stopped before it finished. Your lead is on it.`;
|
|
66
71
|
if (fact.checks === "failed")
|
|
67
72
|
return fact.lead === true
|
|
68
73
|
? `I built ${lower}, but its tests failed. It waits for you: retry or ask for changes.`
|
|
@@ -103,8 +108,9 @@ export function batchLine(facts) {
|
|
|
103
108
|
const shown = names.slice(0, 3).join(", ");
|
|
104
109
|
const first = `${facts.length} ${finishedOnly ? "tasks finished" : "updates"}: ${shown}${names.length > 3 ? ", …" : "."}`;
|
|
105
110
|
// What happens next: anything that broke or needs a decision waits for the person.
|
|
106
|
-
|
|
107
|
-
const
|
|
111
|
+
// What the lead is on waits for nobody.
|
|
112
|
+
const failed = facts.filter(one => one.headline === "Failed" && one.leadOnIt !== true);
|
|
113
|
+
const needs = facts.filter(one => one.headline === "Needs you" && one.leadOnIt !== true).length;
|
|
108
114
|
const broke = failed.length !== 1 ? `${failed.length} failed`
|
|
109
115
|
: failed[0].checks === "failed" ? `tests failed on ${inSentence(failed[0].summary)}` : `${inSentence(failed[0].summary)} stopped before it finished`;
|
|
110
116
|
const waits = [
|
|
@@ -123,16 +129,18 @@ const OLD_NAME = /standing\s*-?\s*orders/i;
|
|
|
123
129
|
* StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
|
|
124
130
|
* leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
|
|
125
131
|
*/
|
|
126
|
-
export async function nameTelegramBot(transport, when) {
|
|
132
|
+
export async function nameTelegramBot(transport, when, signal) {
|
|
127
133
|
try {
|
|
128
|
-
const current = await transport("getMyName", {});
|
|
134
|
+
const current = await transport("getMyName", {}, signal);
|
|
129
135
|
const name = current.ok ? current.result?.name : undefined;
|
|
130
136
|
const shown = typeof name === "string" ? name : null;
|
|
131
137
|
if (shown === BOT_NAME)
|
|
132
138
|
return false;
|
|
133
139
|
if (when === "upgrade" && (shown === null || !OLD_NAME.test(shown)))
|
|
134
140
|
return false;
|
|
135
|
-
|
|
141
|
+
if (signal?.aborted)
|
|
142
|
+
return false;
|
|
143
|
+
const set = await transport("setMyName", { name: BOT_NAME }, signal);
|
|
136
144
|
return set.ok;
|
|
137
145
|
}
|
|
138
146
|
catch {
|
package/dist/cli.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type ParseResult = {
|
|
|
39
39
|
error: string;
|
|
40
40
|
};
|
|
41
41
|
type Write = (line: string) => void;
|
|
42
|
-
export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
|
|
42
|
+
export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll lead say \"<text>\" (the lead) one short message in its person's chat\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
|
|
43
43
|
export declare function parseArgs(argv: readonly string[]): ParseResult;
|
|
44
44
|
/** The commands that operate the queue rather than report on the world. */
|
|
45
45
|
/** Every verb the CLI routes OUTSIDE runOperate. Exported (with
|
package/dist/cli.js
CHANGED
|
@@ -66,6 +66,7 @@ Usage
|
|
|
66
66
|
toolroll connect save a private connection to your central service
|
|
67
67
|
toolroll lead named leads on the connected service
|
|
68
68
|
toolroll lead token mint your lead agent's credential: its own work pings nobody
|
|
69
|
+
toolroll lead say "<text>" (the lead) one short message in its person's chat
|
|
69
70
|
toolroll conversation shared and private conversations on that service
|
|
70
71
|
toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)
|
|
71
72
|
|
package/dist/coding-update.d.ts
CHANGED
|
@@ -10,6 +10,19 @@ export declare function activeCodingUpdateWork(orders: Database): {
|
|
|
10
10
|
/** An idle provider may remain loaded during drain. The normal server stop
|
|
11
11
|
* must close it and establish native/tool exit before either app swap. */
|
|
12
12
|
export declare function assertCodingUpdateStopped(orders: Database): void;
|
|
13
|
+
/** What a stale owner record named when an updater released it, for its ledger. */
|
|
14
|
+
export type ReleasedCodingOwner = {
|
|
15
|
+
pid: number;
|
|
16
|
+
nativePid: number | null;
|
|
17
|
+
};
|
|
18
|
+
/** A runtime killed before its close (deploys over 0.9.11, Oct 2) leaves the
|
|
19
|
+
* owner record naming a dead process, and every later swap refuses. Only an
|
|
20
|
+
* updater that has itself proved each process of the stopped service gone may
|
|
21
|
+
* release it here, and only while nothing the catalog records still lives:
|
|
22
|
+
* the agent, its tools, a session or an unconfirmed message. Returns what it
|
|
23
|
+
* released, or null when the record was already released by an ordinary stop;
|
|
24
|
+
* anything unproven keeps the ordinary refusal. */
|
|
25
|
+
export declare function releaseStaleCodingOwner(orders: Database, stoppedPids: readonly number[], alive?: (pid: number, group: boolean) => boolean): ReleasedCodingOwner | null;
|
|
13
26
|
/** Back up through SQLite so committed WAL records are included. This is a
|
|
14
27
|
* retained recovery artifact; updates never restore the live catalog. */
|
|
15
28
|
export declare function backupCodingCatalog(sourceFile: string, target: string, updateId: string): Promise<void>;
|
package/dist/coding-update.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { chmodSync, closeSync, fsyncSync, lstatSync, openSync } from 'node:fs';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
3
4
|
import { sqliteRuntime } from './sqlite-runtime.js';
|
|
5
|
+
import { processMayBeAlive } from './process-liveness.js';
|
|
4
6
|
const gatePrefix = 'so_coding_update_';
|
|
5
7
|
const pausedMessage = 'Toolroll is updating. Current work can finish; new coding work will resume after the update.';
|
|
6
8
|
export function codingCatalogPath(orders) {
|
|
@@ -100,6 +102,72 @@ export function assertCodingUpdateStopped(orders) {
|
|
|
100
102
|
throw Error('The coding server has not verified agent and tool shutdown. No app was replaced.');
|
|
101
103
|
});
|
|
102
104
|
}
|
|
105
|
+
/** A runtime killed before its close (deploys over 0.9.11, Oct 2) leaves the
|
|
106
|
+
* owner record naming a dead process, and every later swap refuses. Only an
|
|
107
|
+
* updater that has itself proved each process of the stopped service gone may
|
|
108
|
+
* release it here, and only while nothing the catalog records still lives:
|
|
109
|
+
* the agent, its tools, a session or an unconfirmed message. Returns what it
|
|
110
|
+
* released, or null when the record was already released by an ordinary stop;
|
|
111
|
+
* anything unproven keeps the ordinary refusal. */
|
|
112
|
+
export function releaseStaleCodingOwner(orders, stoppedPids, alive = processMayBeAlive) {
|
|
113
|
+
return withCatalog(orders, null, false, db => {
|
|
114
|
+
db.exec('BEGIN IMMEDIATE');
|
|
115
|
+
try {
|
|
116
|
+
const owner = db.prepare('SELECT token,pid,native_pid,clean FROM coding_owner WHERE singleton=1').get();
|
|
117
|
+
const pid = Number(owner['pid']), nativePid = owner['native_pid'] === null ? null : Number(owner['native_pid']);
|
|
118
|
+
if (owner['token'] === '' && pid === 0 && nativePid === null && owner['clean'] === 1) {
|
|
119
|
+
db.exec('ROLLBACK');
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const refuse = (why) => { throw Error(`The coding server has not verified agent and tool shutdown (${why}). No app was replaced.`); };
|
|
123
|
+
// A record its own server released while recovery was still required stays as it is: that needs a person's check.
|
|
124
|
+
if (owner['token'] === '' || !Number.isSafeInteger(pid) || pid <= 0)
|
|
125
|
+
refuse('its saved record needs a recovery check');
|
|
126
|
+
if (!stoppedPids.includes(pid))
|
|
127
|
+
refuse(`process ${pid} is not one this update stopped`);
|
|
128
|
+
if (alive(pid, false))
|
|
129
|
+
refuse(`process ${pid} is still running`);
|
|
130
|
+
// The agent runs in its own process group: a service killed by launchd can leave it behind.
|
|
131
|
+
if (nativePid !== null && alive(nativePid, true))
|
|
132
|
+
refuse(`the agent process ${nativePid} is still running`);
|
|
133
|
+
const custody = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='coding_custody'").get()
|
|
134
|
+
? db.prepare('SELECT payload FROM coding_custody WHERE singleton=1').get() : undefined;
|
|
135
|
+
if (custody) {
|
|
136
|
+
let c;
|
|
137
|
+
try {
|
|
138
|
+
c = JSON.parse(String(custody['payload']));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
c = {};
|
|
142
|
+
}
|
|
143
|
+
const processes = Array.isArray(c.descendants) ? c.descendants : null;
|
|
144
|
+
if (c.host !== hostname() || c.container || c.observationUnknown !== false || processes === null)
|
|
145
|
+
refuse("the agent's tool processes could not be accounted for");
|
|
146
|
+
const recorded = [...(c.pid === null ? [] : [{ pid: c.pid, group: c.group }]), ...processes];
|
|
147
|
+
for (const one of recorded) {
|
|
148
|
+
if (!Number.isSafeInteger(one.pid) || Number(one.pid) <= 0)
|
|
149
|
+
refuse("the agent's tool processes could not be accounted for");
|
|
150
|
+
if (alive(Number(one.pid), one.group === true))
|
|
151
|
+
refuse(`the agent's process ${String(one.pid)} is still running`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const sessions = Number(db.prepare("SELECT count(*) n FROM coding_session WHERE json_extract(document,'$.status') IN ('starting','working','needs-input','stopping','uncertain') OR json_extract(document,'$.turnId') IS NOT NULL").get()?.['n']);
|
|
155
|
+
const deliveries = Number(db.prepare("SELECT count(*) n FROM coding_submission WHERE status IN ('preparing','pending','uncertain')").get()?.['n']);
|
|
156
|
+
if (sessions > 0 || deliveries > 0)
|
|
157
|
+
refuse(`${sessions} coding session(s) and ${deliveries} unconfirmed message(s) were still active`);
|
|
158
|
+
db.prepare("UPDATE coding_owner SET token='',pid=0,native_pid=NULL,clean=1 WHERE singleton=1 AND token=?").run(owner['token']);
|
|
159
|
+
db.exec('COMMIT');
|
|
160
|
+
return { pid, nativePid };
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
try {
|
|
164
|
+
db.exec('ROLLBACK');
|
|
165
|
+
}
|
|
166
|
+
catch { /* already ended */ }
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
103
171
|
function snapshot(db) {
|
|
104
172
|
const data = {};
|
|
105
173
|
// Include every catalog table, including current/future custody records.
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { type DesktopBundle } from "./desktop-bundle.js";
|
|
3
|
+
import { type ReleasedCodingOwner } from "./coding-update.js";
|
|
3
4
|
type Phase = "prepared" | "draining" | "backing-up" | "stopping" | "installing" | "verifying" | "rolling-back" | "releasing" | "complete" | "restored" | "cancelled" | "needs-attention";
|
|
4
5
|
export type UpdateJournal = {
|
|
5
6
|
version: 1;
|
|
@@ -31,6 +32,10 @@ export type UpdateJournal = {
|
|
|
31
32
|
waiting?: LingeringRun & {
|
|
32
33
|
since: string;
|
|
33
34
|
};
|
|
35
|
+
/** The service's supervisor and controller, recorded before it is stopped: the processes proved gone afterwards. */
|
|
36
|
+
stoppedPids?: number[];
|
|
37
|
+
/** A stale coding owner record this update released after proving those processes gone. */
|
|
38
|
+
codingOwnerReleased?: ReleasedCodingOwner;
|
|
34
39
|
};
|
|
35
40
|
type ServiceState = {
|
|
36
41
|
state: string;
|
|
@@ -49,6 +54,8 @@ export type UpdateHooks = {
|
|
|
49
54
|
/** Fault injection for state-machine tests, never selectable by a CLI flag. */
|
|
50
55
|
checkpoint?: (phase: Phase) => void;
|
|
51
56
|
otherControllers?: (bundle: DesktopBundle, stateDir: string) => Promise<boolean>;
|
|
57
|
+
/** Whether a recorded service process still runs (tests). */
|
|
58
|
+
processAlive?: (pid: number) => boolean;
|
|
52
59
|
};
|
|
53
60
|
export declare const updateTerminal: (phase: string) => boolean;
|
|
54
61
|
/** Durable receipts are private and independent of the app being replaced. */
|
package/dist/desktop-update.js
CHANGED
|
@@ -10,7 +10,8 @@ import { run } from "./exec.js";
|
|
|
10
10
|
import { readSchemaVersion, SCHEMA_VERSION, Store } from "./store.js";
|
|
11
11
|
import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused } from "./desktop-update-gate.js";
|
|
12
12
|
import { currentDesktopAccess } from "./desktop-access.js";
|
|
13
|
-
import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists } from "./coding-update.js";
|
|
13
|
+
import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner } from "./coding-update.js";
|
|
14
|
+
import { processMayBeAlive } from "./process-liveness.js";
|
|
14
15
|
/** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
|
|
15
16
|
* and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
|
|
16
17
|
function sqlite() {
|
|
@@ -76,6 +77,8 @@ export function readUpdateJournal(stateDir, retainedReceipt) {
|
|
|
76
77
|
throw Error("The recorded replacement state is invalid. Nothing was changed.");
|
|
77
78
|
if (j.codingCatalogExpected !== undefined && typeof j.codingCatalogExpected !== "boolean")
|
|
78
79
|
throw Error("The recorded coding catalog presence is invalid. Nothing was changed.");
|
|
80
|
+
if (j.stoppedPids !== undefined && (!Array.isArray(j.stoppedPids) || !j.stoppedPids.every(pid => Number.isSafeInteger(pid) && pid > 1)))
|
|
81
|
+
throw Error("The recorded service processes are invalid. Nothing was changed.");
|
|
79
82
|
if ((j.codingBackupPath === undefined) !== (j.codingBackupHash === undefined) || (j.codingBackupPath && (dirname(j.codingBackupPath) !== j.workDir || !/^coding\.backup(?:\.[a-f0-9-]{36})?\.sqlite$/.test(basename(j.codingBackupPath)))) || (j.codingBackupHash && !/^[a-f0-9]{64}$/.test(j.codingBackupHash)))
|
|
80
83
|
throw Error("The retained coding backup paths or identity are invalid. Nothing was changed.");
|
|
81
84
|
for (const directory of [dirname(j.workDir), j.workDir]) {
|
|
@@ -537,6 +540,34 @@ function assertDesktopCodingStopped(j, db) {
|
|
|
537
540
|
assertCodingCatalogPresent(j);
|
|
538
541
|
assertCodingUpdateStopped(db);
|
|
539
542
|
}
|
|
543
|
+
/** The service's own processes, from its supervisor's status file: read before the stop, never after. */
|
|
544
|
+
function servicePids(stateDir) {
|
|
545
|
+
try {
|
|
546
|
+
const status = JSON.parse(readFileSync(join(stateDir, "controller-supervisor.json"), "utf8"));
|
|
547
|
+
return [status.supervisorPid, status.controllerPid].filter((pid) => Number.isSafeInteger(pid) && Number(pid) > 1);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/** An older app killed before its close leaves the coding owner record behind. Once the stopped service's processes
|
|
554
|
+
* are proved gone, it is released here (and recorded in the ledger); anything unproven keeps the ordinary refusal. */
|
|
555
|
+
function releaseDesktopCodingOwner(j, db, alive) {
|
|
556
|
+
assertCodingCatalogPresent(j);
|
|
557
|
+
const released = releaseStaleCodingOwner(db, j.stoppedPids ?? [], (pid, group) => alive(pid) || processMayBeAlive(pid, group));
|
|
558
|
+
if (released === null)
|
|
559
|
+
return;
|
|
560
|
+
j.codingOwnerReleased = released;
|
|
561
|
+
save(j, j.phase, j.detail);
|
|
562
|
+
const ledger = connect(j.databaseFile);
|
|
563
|
+
try {
|
|
564
|
+
ledger.prepare("INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (?,'desktop update',NULL,NULL,NULL,'coding owner released','released','policy',?)")
|
|
565
|
+
.run(new Date().toISOString(), `${j.old.version} stopped without releasing the coding workspace; process ${released.pid}${released.nativePid === null ? "" : ` and agent ${released.nativePid}`} proved gone`);
|
|
566
|
+
}
|
|
567
|
+
finally {
|
|
568
|
+
ledger.close();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
540
571
|
function assertCodingBackup(j) {
|
|
541
572
|
assertCodingCatalogPresent(j);
|
|
542
573
|
if (j.codingBackupPath || j.codingBackupHash) {
|
|
@@ -815,15 +846,25 @@ export async function runDesktopUpdate(stateDir, hooks = {}) {
|
|
|
815
846
|
throw Error("The installed app changed after preview. Nothing was replaced.");
|
|
816
847
|
const current = actual === j.old.hash ? j.old : atInstalled(j.next, j);
|
|
817
848
|
j.serviceInterrupted = true;
|
|
849
|
+
// Recorded once, before the first stop: a resumed update keeps the processes it first saw.
|
|
850
|
+
if (j.stoppedPids === undefined)
|
|
851
|
+
j.stoppedPids = servicePids(j.stateDir);
|
|
818
852
|
checkpoint("stopping", "Current work is finished. Stopping the background service for the update.");
|
|
819
853
|
await control("stop", current, j);
|
|
820
854
|
const stopped = await status(current, j);
|
|
821
855
|
if (["running", "loaded"].includes(stopped.state))
|
|
822
856
|
throw Error("The background service did not stop. No app swap was attempted.");
|
|
857
|
+
const alive = hooks.processAlive ?? (pid => processMayBeAlive(pid, false));
|
|
858
|
+
for (let waited = 0; j.stoppedPids.some(alive); waited += 250) {
|
|
859
|
+
if (waited >= 60_000)
|
|
860
|
+
throw Error(`The background service is still running (process ${j.stoppedPids.filter(alive).join(", ")}) after it was stopped. No app swap was attempted.`);
|
|
861
|
+
await sleep(250);
|
|
862
|
+
}
|
|
823
863
|
const afterStop = connect(j.databaseFile, true);
|
|
824
864
|
try {
|
|
825
865
|
if (Object.values(activeUpdateWork(afterStop)).some(n => n !== 0) || afterStop.prepare("SELECT 1 FROM watch_lease WHERE expires_at>? LIMIT 1").get(new Date().toISOString()))
|
|
826
866
|
throw Error("A worker or active operation still uses this database. Stop the other controller before continuing the update.");
|
|
867
|
+
releaseDesktopCodingOwner(j, afterStop, alive);
|
|
827
868
|
assertDesktopCodingStopped(j, afterStop);
|
|
828
869
|
}
|
|
829
870
|
finally {
|