@saadnvd1/agent-os 0.3.597 → 0.3.601
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/app/api/bus/spawn/route.ts +35 -16
- package/app/api/lumifyhub/cards/[cardId]/run/route.ts +3 -0
- package/app/api/tasks/route.ts +3 -0
- package/app/page.tsx +2 -1
- package/components/SessionList/LiveLine.tsx +33 -6
- package/components/SessionList/QueuedRow.tsx +45 -46
- package/components/SessionList/RowParts.tsx +1 -54
- package/components/SessionList/RowShell.tsx +13 -6
- package/components/SessionList/RowStatusPlacement.test.ts +114 -0
- package/components/SessionList/RowSubtitle.tsx +2 -3
- package/components/SessionList/SessionRow.tsx +14 -11
- package/lib/agents/spawn.ts +16 -4
- package/lib/chat/refresh.ts +2 -1
- package/lib/chat/runner.ts +29 -0
- package/lib/chat/store.ts +53 -0
- package/lib/db/migrations/20261014T2110_task_start_record.ts +21 -0
- package/lib/db/migrations/20261014T2200_end_orphaned_tasks.ts +23 -0
- package/lib/db/types.ts +2 -0
- package/lib/learning-cards/apply.ts +7 -1
- package/lib/models/plan-step.ts +25 -8
- package/lib/models/resolve.ts +1 -1
- package/lib/models/resume.ts +1 -1
- package/lib/models/roles.ts +0 -16
- package/lib/models/session-role.ts +19 -0
- package/lib/orchestrator/act.ts +22 -7
- package/lib/orchestrator/amendments.ts +10 -3
- package/lib/orchestrator/serve.ts +1 -0
- package/lib/orchestrator/testing.ts +8 -0
- package/lib/orchestrator/tool-schemas.ts +9 -0
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/plans/claim.ts +131 -0
- package/lib/plans/release.ts +5 -2
- package/lib/recipes/run.ts +6 -0
- package/lib/schedules/start.ts +1 -0
- package/lib/sessions/fork.ts +10 -0
- package/lib/sessions/launch.ts +5 -1
- package/lib/sidebar/live-status.ts +33 -2
- package/lib/sidebar/row-badge.ts +26 -2
- package/lib/stacks/start.ts +1 -0
- package/lib/tasks/held-remote.ts +4 -2
- package/lib/tasks/held-replies.ts +5 -2
- package/lib/tasks/held.ts +2 -1
- package/lib/tasks/index.ts +85 -46
- package/lib/tasks/move-carry.ts +12 -1
- package/lib/tasks/move-guard.ts +2 -1
- package/lib/tasks/move.ts +7 -5
- package/lib/tasks/pause.ts +1 -1
- package/lib/tasks/queue.ts +54 -31
- package/lib/tasks/remote.ts +3 -1
- package/lib/tasks/starter.ts +124 -0
- package/lib/usage/calls.ts +2 -1
- package/lib/usage/turns.ts +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// A task that builds a waiting piece of a plan claims it, so the release
|
|
2
|
+
// sweep never starts that piece a second time.
|
|
3
|
+
|
|
4
|
+
import { db } from "../db";
|
|
5
|
+
import { getProject } from "../projects";
|
|
6
|
+
import type { FeaturePiece } from "./features";
|
|
7
|
+
|
|
8
|
+
export interface WaitingPiece extends FeaturePiece {
|
|
9
|
+
feature_name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const COLUMNS = `fp.*, f.name AS feature_name`;
|
|
13
|
+
|
|
14
|
+
// Waiting pieces in `projectId` of the plans the caller belongs to: the plan
|
|
15
|
+
// it wrote, or the one whose piece it is building.
|
|
16
|
+
export function waitingPiecesFor(
|
|
17
|
+
callerId: string,
|
|
18
|
+
projectId: string
|
|
19
|
+
): WaitingPiece[] {
|
|
20
|
+
return db
|
|
21
|
+
.prepare(
|
|
22
|
+
`SELECT ${COLUMNS} FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id
|
|
23
|
+
WHERE f.status = 'building' AND fp.status = 'waiting' AND fp.project_id = ?
|
|
24
|
+
AND (f.plan_session_id = ?
|
|
25
|
+
OR EXISTS (SELECT 1 FROM feature_pieces o
|
|
26
|
+
WHERE o.feature_id = f.id AND o.session_id = ?))
|
|
27
|
+
ORDER BY f.created_at, fp.position`
|
|
28
|
+
)
|
|
29
|
+
.all(projectId, callerId, callerId) as WaitingPiece[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The orchestrator's view: waiting pieces of its workspace's plans.
|
|
33
|
+
export function workspacePieces(
|
|
34
|
+
workspaceId: string,
|
|
35
|
+
projectId: string
|
|
36
|
+
): WaitingPiece[] {
|
|
37
|
+
return db
|
|
38
|
+
.prepare(
|
|
39
|
+
`SELECT ${COLUMNS} FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id
|
|
40
|
+
WHERE f.status = 'building' AND fp.status = 'waiting' AND fp.project_id = ?
|
|
41
|
+
AND f.workspace_id = ?
|
|
42
|
+
ORDER BY f.created_at, fp.position`
|
|
43
|
+
)
|
|
44
|
+
.all(projectId, workspaceId) as WaitingPiece[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const listPieces = (waiting: WaitingPiece[]) =>
|
|
48
|
+
waiting.map((p) => `${p.position} ${p.name}`).join(", ");
|
|
49
|
+
|
|
50
|
+
// `name`, `position`, or `feature/name`; an error that lists what's waiting
|
|
51
|
+
// when nothing, or more than one, matches.
|
|
52
|
+
export function findPiece(waiting: WaitingPiece[], ref: string): WaitingPiece {
|
|
53
|
+
const want = ref.trim().toLowerCase();
|
|
54
|
+
const slash = want.indexOf("/");
|
|
55
|
+
const only = slash > 0 ? want.slice(0, slash) : null;
|
|
56
|
+
const piece = slash > 0 ? want.slice(slash + 1) : want;
|
|
57
|
+
const hits = waiting.filter(
|
|
58
|
+
(p) =>
|
|
59
|
+
(!only || p.feature_name.toLowerCase() === only) &&
|
|
60
|
+
(p.name.toLowerCase() === piece || String(p.position) === piece)
|
|
61
|
+
);
|
|
62
|
+
if (hits.length === 1) return hits[0];
|
|
63
|
+
const names = waiting
|
|
64
|
+
.map((p) =>
|
|
65
|
+
waiting.some((o) => o.feature_id !== p.feature_id)
|
|
66
|
+
? `${p.feature_name}/${p.name}`
|
|
67
|
+
: `${p.position} ${p.name}`
|
|
68
|
+
)
|
|
69
|
+
.join(", ");
|
|
70
|
+
if (hits.length > 1)
|
|
71
|
+
throw new Error(
|
|
72
|
+
`"${ref}" matches more than one waiting piece: ${names}. Name it as feature/piece.`
|
|
73
|
+
);
|
|
74
|
+
throw new Error(
|
|
75
|
+
waiting.length
|
|
76
|
+
? `No waiting piece "${ref}". Waiting pieces: ${names}.`
|
|
77
|
+
: `No waiting piece "${ref}": nothing in your plan is waiting in this project.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function pieceRefusal(
|
|
82
|
+
waiting: WaitingPiece[],
|
|
83
|
+
projectId: string
|
|
84
|
+
): string {
|
|
85
|
+
const where = getProject(projectId)?.name ?? "this project";
|
|
86
|
+
return `Your plan "${waiting[0].feature_name}" has pieces waiting in ${where}: ${listPieces(waiting)}. Name the one this task builds with --piece <name>; AgentOS starts the others itself when they are due.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function pieceStatus(id: number): string | undefined {
|
|
90
|
+
return (
|
|
91
|
+
db.prepare(`SELECT status FROM feature_pieces WHERE id = ?`).get(id) as
|
|
92
|
+
| { status: string }
|
|
93
|
+
| undefined
|
|
94
|
+
)?.status;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function claimable(id: number): void {
|
|
98
|
+
const status = pieceStatus(id);
|
|
99
|
+
if (status !== "waiting")
|
|
100
|
+
throw new Error(`That piece is ${status ?? "gone"}, not waiting`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Synchronous, so nothing runs between the check and the write.
|
|
104
|
+
export function claimPiece(id: number, sessionId: string): void {
|
|
105
|
+
const changed = db
|
|
106
|
+
.prepare(
|
|
107
|
+
`UPDATE feature_pieces SET status = 'building', session_id = ?, note = NULL
|
|
108
|
+
WHERE id = ? AND status = 'waiting'`
|
|
109
|
+
)
|
|
110
|
+
.run(sessionId, id).changes;
|
|
111
|
+
if (!changed)
|
|
112
|
+
throw new Error(`That piece is ${pieceStatus(id) ?? "gone"}, not waiting`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Gives a claim back when the session it was made for never came to be.
|
|
116
|
+
export function releaseClaim(id: number, sessionId: string): void {
|
|
117
|
+
db.prepare(
|
|
118
|
+
`UPDATE feature_pieces SET status = 'waiting', session_id = NULL
|
|
119
|
+
WHERE id = ? AND session_id = ? AND status = 'building'`
|
|
120
|
+
).run(id, sessionId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function pieceLabel(id: number): string {
|
|
124
|
+
const r = db
|
|
125
|
+
.prepare(
|
|
126
|
+
`SELECT fp.position, fp.name, f.name AS feature FROM feature_pieces fp
|
|
127
|
+
JOIN features f ON f.id = fp.feature_id WHERE fp.id = ?`
|
|
128
|
+
)
|
|
129
|
+
.get(id) as { position: number; name: string; feature: string } | undefined;
|
|
130
|
+
return r ? `piece ${r.position} ${r.name} of "${r.feature}"` : "a piece";
|
|
131
|
+
}
|
package/lib/plans/release.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { createTask } from "../tasks";
|
|
|
9
9
|
import { startOrQueue } from "../tasks/queue";
|
|
10
10
|
import { braked, BrakeRefused } from "../orchestrator/brakes";
|
|
11
11
|
import { queueEvent, queueEventOnce } from "../orchestrator/events";
|
|
12
|
-
import { amendBrief,
|
|
12
|
+
import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
|
|
13
13
|
import { usageRows } from "../session-logs/gather";
|
|
14
14
|
import { pieceBrief } from "./pieces";
|
|
15
15
|
import {
|
|
@@ -122,6 +122,9 @@ export const realDeps: ReleaseDeps = {
|
|
|
122
122
|
// A retry under the same id clears what a failed attempt left behind.
|
|
123
123
|
reclaim: true,
|
|
124
124
|
startedBy: plan?.started_by ?? null,
|
|
125
|
+
parentSessionId: feature.plan_session_id,
|
|
126
|
+
startedVia: "plan" as const,
|
|
127
|
+
startedByDevice: plan?.started_by_device ?? null,
|
|
125
128
|
originWorkspaceId: feature.workspace_id,
|
|
126
129
|
};
|
|
127
130
|
// A queued task keeps its queue id as its session id once it starts.
|
|
@@ -173,7 +176,7 @@ function creditParentMerge(feature: Feature, all: FeaturePiece[]): void {
|
|
|
173
176
|
)
|
|
174
177
|
)
|
|
175
178
|
return;
|
|
176
|
-
if (
|
|
179
|
+
if (scopeChangesOf(parent.id).length) return;
|
|
177
180
|
const covered = all.filter(
|
|
178
181
|
(p) => p.status === "waiting" && p.project_id === parent.project_id
|
|
179
182
|
);
|
package/lib/recipes/run.ts
CHANGED
|
@@ -152,6 +152,8 @@ export async function startRun(
|
|
|
152
152
|
access: "full",
|
|
153
153
|
model: typeof model === "string" ? model : null,
|
|
154
154
|
startedBy,
|
|
155
|
+
parentSessionId: startedBy,
|
|
156
|
+
startedVia: byOrchestrator ? "orchestrator" : "recipe",
|
|
155
157
|
}).then((l) => ({ ...l, id: l.session.id }))
|
|
156
158
|
);
|
|
157
159
|
settleStart(run.id, "running", { sessionId: session.id });
|
|
@@ -167,6 +169,8 @@ export async function startRun(
|
|
|
167
169
|
plan: false,
|
|
168
170
|
planReason: "recipe run",
|
|
169
171
|
startedBy,
|
|
172
|
+
parentSessionId: startedBy,
|
|
173
|
+
startedVia: byOrchestrator ? "orchestrator" : "recipe",
|
|
170
174
|
originWorkspaceId: byOrchestrator ? ws : undefined,
|
|
171
175
|
},
|
|
172
176
|
() =>
|
|
@@ -181,6 +185,8 @@ export async function startRun(
|
|
|
181
185
|
plan: false,
|
|
182
186
|
planReason: "recipe run",
|
|
183
187
|
startedBy,
|
|
188
|
+
parentSessionId: startedBy,
|
|
189
|
+
startedVia: byOrchestrator ? "orchestrator" : "recipe",
|
|
184
190
|
})
|
|
185
191
|
)
|
|
186
192
|
);
|
package/lib/schedules/start.ts
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// A fork continues its parent's conversation: it has a parent but wasn't
|
|
2
|
+
// started by anyone and isn't a task. A task or session started from another
|
|
3
|
+
// session also records that parent, and is not a fork.
|
|
4
|
+
export const isFork = (s: {
|
|
5
|
+
parent_session_id?: string | null;
|
|
6
|
+
started_by?: string | null;
|
|
7
|
+
started_via?: string | null;
|
|
8
|
+
task_prompt?: string | null;
|
|
9
|
+
}): boolean =>
|
|
10
|
+
!!s.parent_session_id && !s.started_by && !s.started_via && !s.task_prompt;
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* then (for a worktree) the setup in the background, then the first message.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { StartVia } from "../tasks/starter";
|
|
6
7
|
import fs from "fs";
|
|
7
8
|
import os from "os";
|
|
8
9
|
import path from "path";
|
|
@@ -74,6 +75,7 @@ export interface LaunchInput {
|
|
|
74
75
|
// The orchestrator session that starts it: it runs on the implementation
|
|
75
76
|
// role's model (or `model`), not the interactive one's.
|
|
76
77
|
startedBy?: string | null;
|
|
78
|
+
startedVia?: StartVia;
|
|
77
79
|
groupPath?: string;
|
|
78
80
|
systemPrompt?: string | null;
|
|
79
81
|
}
|
|
@@ -277,12 +279,14 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
277
279
|
runOn
|
|
278
280
|
);
|
|
279
281
|
db.prepare(
|
|
280
|
-
`UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by =
|
|
282
|
+
`UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by = ?,
|
|
283
|
+
started_via = ? WHERE id = ?`
|
|
281
284
|
).run(
|
|
282
285
|
view,
|
|
283
286
|
naming.source,
|
|
284
287
|
useWorktree ? "running" : null,
|
|
285
288
|
input.startedBy ?? null,
|
|
289
|
+
input.startedVia ?? (input.startedBy ? "orchestrator" : null),
|
|
286
290
|
id
|
|
287
291
|
);
|
|
288
292
|
if (interactive) setSessionModel(id, interactive);
|
|
@@ -4,16 +4,37 @@ import {
|
|
|
4
4
|
type PrStage,
|
|
5
5
|
type PrStageName,
|
|
6
6
|
} from "../tasks/pr-stage";
|
|
7
|
+
import type { StatusBadge } from "./row-badge";
|
|
7
8
|
|
|
8
|
-
export type LiveTone =
|
|
9
|
+
export type LiveTone =
|
|
10
|
+
| "blue"
|
|
11
|
+
| "amber"
|
|
12
|
+
| "red"
|
|
13
|
+
| "green"
|
|
14
|
+
| "purple"
|
|
15
|
+
| "primary"
|
|
16
|
+
| "muted";
|
|
9
17
|
|
|
10
18
|
export interface LivePart {
|
|
11
19
|
text: string;
|
|
12
20
|
tone: LiveTone;
|
|
13
21
|
// Still moving on its own: the UI shows a spinner.
|
|
14
22
|
busy?: boolean;
|
|
23
|
+
// A status badge (need, stalled, plan step): drawn as a pill, with an icon
|
|
24
|
+
// for the plan kinds.
|
|
25
|
+
badge?: StatusBadge["kind"];
|
|
26
|
+
tip?: string;
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
export const BADGE_TONE: Record<StatusBadge["kind"], LiveTone> = {
|
|
30
|
+
need: "amber",
|
|
31
|
+
failed: "red",
|
|
32
|
+
stalled: "amber",
|
|
33
|
+
starting: "muted",
|
|
34
|
+
planning: "blue",
|
|
35
|
+
"plan-ready": "primary",
|
|
36
|
+
};
|
|
37
|
+
|
|
17
38
|
export const PR_STAGE_TONE: Record<PrStageName, LiveTone> = {
|
|
18
39
|
merged: "purple",
|
|
19
40
|
conflicts: "amber",
|
|
@@ -28,14 +49,24 @@ export const PR_STAGE_TONE: Record<PrStageName, LiveTone> = {
|
|
|
28
49
|
};
|
|
29
50
|
|
|
30
51
|
// What the row's second line says about the session right now, in order:
|
|
31
|
-
// "Compacting", then
|
|
52
|
+
// "Compacting", then a status badge (Needs you, Stalled, Planning, Plan
|
|
53
|
+
// ready), then "PR CI". Empty when there is nothing to say.
|
|
32
54
|
export function liveParts(input: {
|
|
33
55
|
compacting?: boolean;
|
|
34
56
|
prStage?: PrStage;
|
|
57
|
+
badge?: StatusBadge | null;
|
|
35
58
|
}): LivePart[] {
|
|
36
59
|
const parts: LivePart[] = [];
|
|
37
60
|
if (input.compacting)
|
|
38
61
|
parts.push({ text: "Compacting", tone: "blue", busy: true });
|
|
62
|
+
const b = input.badge;
|
|
63
|
+
if (b)
|
|
64
|
+
parts.push({
|
|
65
|
+
text: b.label,
|
|
66
|
+
tone: BADGE_TONE[b.kind],
|
|
67
|
+
badge: b.kind,
|
|
68
|
+
tip: b.tip,
|
|
69
|
+
});
|
|
39
70
|
const s = input.prStage;
|
|
40
71
|
if (s) {
|
|
41
72
|
const word = PR_STAGE_SHORT[s.stage];
|
package/lib/sidebar/row-badge.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// The plan badge a row shows
|
|
1
|
+
// The plan badge a row shows: writing its plan, or a plan
|
|
2
2
|
// waiting for approval. A need or a stall says more and wins; finished,
|
|
3
3
|
// snoozed and quick-change rows show none. Client-safe.
|
|
4
|
-
import type
|
|
4
|
+
import { NEED_LABEL, type SidebarRow } from "./shelves";
|
|
5
5
|
|
|
6
6
|
export type RowKindBadge = {
|
|
7
7
|
kind: "planning" | "plan-ready";
|
|
@@ -38,3 +38,27 @@ export function rowKindBadge(
|
|
|
38
38
|
};
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
// The one status badge a row's second line carries: what it needs from you,
|
|
43
|
+
// stalled, or its plan step. They are mutually exclusive by precedence. The
|
|
44
|
+
// queued rows' Failed/Starting use the same shape.
|
|
45
|
+
export type StatusBadge = {
|
|
46
|
+
kind: "need" | "failed" | "stalled" | "starting" | RowKindBadge["kind"];
|
|
47
|
+
label: string;
|
|
48
|
+
tip?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function rowStatusBadge(
|
|
52
|
+
row: Parameters<typeof rowKindBadge>[0] &
|
|
53
|
+
Pick<SidebarRow, "need"> & { status?: SidebarRow["status"] }
|
|
54
|
+
): StatusBadge | null {
|
|
55
|
+
if (row.need) {
|
|
56
|
+
const asks = row.status?.asks ?? 0;
|
|
57
|
+
return {
|
|
58
|
+
kind: row.need === "failed" ? "failed" : "need",
|
|
59
|
+
label: `${NEED_LABEL[row.need]}${asks > 1 ? ` ${asks}` : ""}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (row.status?.stalled) return { kind: "stalled", label: "Stalled" };
|
|
63
|
+
return rowKindBadge(row);
|
|
64
|
+
}
|
package/lib/stacks/start.ts
CHANGED
package/lib/tasks/held-remote.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import type { Delivery } from "../bus/delivery";
|
|
17
17
|
import { wakeLine } from "../bus/format";
|
|
18
18
|
import { orchestratedStart } from "./held";
|
|
19
|
+
import { orchestratorOf } from "./starter";
|
|
19
20
|
|
|
20
21
|
const path = (id: string, leaf: string) =>
|
|
21
22
|
`/api/tasks/${encodeURIComponent(id)}/${leaf}`;
|
|
@@ -23,11 +24,12 @@ const path = (id: string, leaf: string) =>
|
|
|
23
24
|
/**
|
|
24
25
|
* A task its orchestrator put on a linked machine, whether by moving it or
|
|
25
26
|
* by the queue placing it there: the one test every relayed call goes by.
|
|
26
|
-
* (held-replies.ts reads the same rule in SQL: started_by
|
|
27
|
+
* (held-replies.ts reads the same rule in SQL: started_by is an orchestrator.)
|
|
27
28
|
*/
|
|
28
29
|
export const isHeldRemote = (
|
|
29
30
|
s: Pick<Session, "started_by" | "task_prompt" | "host_id">
|
|
30
|
-
): boolean =>
|
|
31
|
+
): boolean =>
|
|
32
|
+
!!s.task_prompt && !!hostLink(s.host_id) && !!orchestratorOf(s.started_by);
|
|
31
33
|
|
|
32
34
|
type Held = Pick<
|
|
33
35
|
Session,
|
|
@@ -27,18 +27,21 @@ interface Pulled {
|
|
|
27
27
|
body: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Held means an orchestrator started it, as isHeldRemote reads it.
|
|
31
|
+
const HELD_BY = `started_by IN (SELECT id FROM sessions WHERE role = 'orchestrator')`;
|
|
32
|
+
|
|
30
33
|
const heldMirror = (hostId: string, id: string) =>
|
|
31
34
|
db
|
|
32
35
|
.prepare(
|
|
33
36
|
`SELECT * FROM sessions WHERE id = ? AND host_id = ?
|
|
34
|
-
AND
|
|
37
|
+
AND ${HELD_BY} AND task_status IN ('running', 'moving')`
|
|
35
38
|
)
|
|
36
39
|
.get(id, hostId) as Session | undefined;
|
|
37
40
|
|
|
38
41
|
export function hasHeldMirrors(hostId: string): boolean {
|
|
39
42
|
return !!db
|
|
40
43
|
.prepare(
|
|
41
|
-
`SELECT 1 FROM sessions WHERE host_id = ? AND
|
|
44
|
+
`SELECT 1 FROM sessions WHERE host_id = ? AND ${HELD_BY}
|
|
42
45
|
AND task_prompt IS NOT NULL AND task_status IN ('running', 'moving')`
|
|
43
46
|
)
|
|
44
47
|
.get(hostId);
|
package/lib/tasks/held.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* a green CI, past the gates its orchestrator judges it by.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { orchestratorOf } from "./starter";
|
|
8
9
|
import os from "os";
|
|
9
10
|
import { db, type Session } from "../db";
|
|
10
11
|
import type { OrchestratedBundle } from "./move-bundle";
|
|
@@ -102,7 +103,7 @@ type Holdable = Pick<
|
|
|
102
103
|
export function mayHold(s: Holdable, device: string): boolean {
|
|
103
104
|
if (!s.task_prompt || s.task_status !== "running") return false;
|
|
104
105
|
if (s.host_id && s.host_id !== "local") return false;
|
|
105
|
-
if (s.started_by || s.held_by_device) return false;
|
|
106
|
+
if (orchestratorOf(s.started_by) || s.held_by_device) return false;
|
|
106
107
|
return (
|
|
107
108
|
s.started_by_device === device || s.started_by_device === UNRECORDED_STARTER
|
|
108
109
|
);
|
package/lib/tasks/index.ts
CHANGED
|
@@ -48,7 +48,9 @@ import {
|
|
|
48
48
|
} from "./state";
|
|
49
49
|
import { expandHome, prFor, storedPR, taskSessions } from "./session";
|
|
50
50
|
import { InProgressError, type OrchestratedBundle } from "./move-bundle";
|
|
51
|
-
import { holdFor, mayHold
|
|
51
|
+
import { holdFor, mayHold } from "./held";
|
|
52
|
+
import { orchestratorOf, recordStart, type StartVia } from "./starter";
|
|
53
|
+
import { claimable, claimPiece, releaseClaim } from "../plans/claim";
|
|
52
54
|
import { nameFor } from "../session-titles";
|
|
53
55
|
import { taskSetupOf, type TaskSetup } from "./setup";
|
|
54
56
|
import { taskPlan } from "./plan-view";
|
|
@@ -136,8 +138,13 @@ export async function createTask(opts: {
|
|
|
136
138
|
planReason?: string | null;
|
|
137
139
|
// Stop after the plan and wait for approval (implies plan).
|
|
138
140
|
planOnly?: boolean;
|
|
139
|
-
// The
|
|
141
|
+
// The session that started it (null: a person), the session it came from,
|
|
142
|
+
// and how: every start says (tasks/starter.ts).
|
|
140
143
|
startedBy?: string | null;
|
|
144
|
+
parentSessionId?: string | null;
|
|
145
|
+
startedVia: StartVia;
|
|
146
|
+
// The waiting plan piece it builds: claimed for this task.
|
|
147
|
+
pieceId?: number;
|
|
141
148
|
// Started from this LumifyHub card, which the task then moves.
|
|
142
149
|
cardId?: string;
|
|
143
150
|
// Stacked: cut from another task's pushed branch, at this exact commit.
|
|
@@ -165,7 +172,7 @@ export async function createTask(opts: {
|
|
|
165
172
|
// (held.ts): that machine's paired device and its description of the task.
|
|
166
173
|
held?: { device: string; orchestrated: OrchestratedBundle };
|
|
167
174
|
// The paired device that sent this keyed start: the only one that may hold it later.
|
|
168
|
-
startedByDevice?: string;
|
|
175
|
+
startedByDevice?: string | null;
|
|
169
176
|
}): Promise<Session> {
|
|
170
177
|
if (opts.id) {
|
|
171
178
|
const existing = queries.getSession(db).get(opts.id) as Session | undefined;
|
|
@@ -222,6 +229,7 @@ async function startTask(
|
|
|
222
229
|
if (!prompt) throw new Error("Describe the task");
|
|
223
230
|
const project = getProject(opts.projectId);
|
|
224
231
|
if (!project || project.is_uncategorized) throw new Error("Pick a project");
|
|
232
|
+
if (opts.pieceId) claimable(opts.pieceId);
|
|
225
233
|
const hostId = opts.hostId ?? project.host_id;
|
|
226
234
|
const view = opts.view ?? DEFAULT_START_VIEW;
|
|
227
235
|
const agent = opts.agent ?? "claude";
|
|
@@ -251,6 +259,16 @@ async function startTask(
|
|
|
251
259
|
view,
|
|
252
260
|
startedBy: opts.startedBy,
|
|
253
261
|
});
|
|
262
|
+
// The mirror's started_by stays orchestrator-only: a held-remote task is
|
|
263
|
+
// relayed, stopped and answered through it. A plain calling task is its
|
|
264
|
+
// parent and started_via only.
|
|
265
|
+
recordStart(session.id, {
|
|
266
|
+
startedBy: orchestratorOf(opts.startedBy),
|
|
267
|
+
parentSessionId: opts.parentSessionId ?? null,
|
|
268
|
+
startedVia: opts.startedVia,
|
|
269
|
+
startedByDevice: opts.held?.device ?? opts.startedByDevice,
|
|
270
|
+
});
|
|
271
|
+
if (opts.pieceId) claimPiece(opts.pieceId, session.id);
|
|
254
272
|
opts.onCreated?.(session.id);
|
|
255
273
|
return session;
|
|
256
274
|
}
|
|
@@ -295,50 +313,71 @@ async function startTask(
|
|
|
295
313
|
const model = routing.first.model;
|
|
296
314
|
const tmuxName = `${provider.id}-${id}`;
|
|
297
315
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
316
|
+
// Claimed right before the row exists, with nothing awaited between, so the
|
|
317
|
+
// release sweep can't start the same piece.
|
|
318
|
+
if (opts.pieceId) {
|
|
319
|
+
try {
|
|
320
|
+
claimPiece(opts.pieceId, id);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
await discardLeftoverStart(projectPath, feature).catch(() => {});
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
queries
|
|
328
|
+
.createSession(db)
|
|
329
|
+
.run(
|
|
330
|
+
id,
|
|
331
|
+
naming.name,
|
|
332
|
+
tmuxName,
|
|
333
|
+
wt.worktreePath,
|
|
334
|
+
null,
|
|
335
|
+
model,
|
|
336
|
+
null,
|
|
337
|
+
"sessions",
|
|
338
|
+
agent,
|
|
339
|
+
1,
|
|
340
|
+
project.id,
|
|
341
|
+
"local"
|
|
342
|
+
);
|
|
343
|
+
queries
|
|
344
|
+
.updateSessionWorktree(db)
|
|
345
|
+
.run(wt.worktreePath, wt.branchName, baseBranch, null, id);
|
|
346
|
+
// Everything the launch needs is on the row, so a restart can resume it.
|
|
347
|
+
db.prepare(
|
|
348
|
+
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
|
|
349
|
+
model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
|
|
350
|
+
plan_decided = ?, plan_reason = ? WHERE id = ?`
|
|
351
|
+
).run(
|
|
352
|
+
prompt,
|
|
353
|
+
buildTaskBrief({
|
|
354
|
+
branch: wt.branchName,
|
|
355
|
+
baseBranch,
|
|
356
|
+
stack: opts.base?.stack,
|
|
357
|
+
}),
|
|
358
|
+
naming.source,
|
|
359
|
+
view,
|
|
360
|
+
routing.first.provider.id === ANTHROPIC
|
|
361
|
+
? null
|
|
362
|
+
: routing.first.provider.id,
|
|
363
|
+
routing.override ? JSON.stringify(routing.override) : null,
|
|
364
|
+
routing.plan ? "planning" : null,
|
|
365
|
+
routing.planOnly ? 1 : null,
|
|
366
|
+
routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
|
|
367
|
+
opts.planReason?.trim() || null,
|
|
368
|
+
id
|
|
313
369
|
);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
branch: wt.branchName,
|
|
326
|
-
baseBranch,
|
|
327
|
-
stack: opts.base?.stack,
|
|
328
|
-
}),
|
|
329
|
-
naming.source,
|
|
330
|
-
view,
|
|
331
|
-
routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
|
|
332
|
-
routing.override ? JSON.stringify(routing.override) : null,
|
|
333
|
-
routing.plan ? "planning" : null,
|
|
334
|
-
routing.planOnly ? 1 : null,
|
|
335
|
-
routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
|
|
336
|
-
opts.planReason?.trim() || null,
|
|
337
|
-
opts.startedBy ?? null,
|
|
338
|
-
id
|
|
339
|
-
);
|
|
340
|
-
const starter = opts.held?.device ?? opts.startedByDevice;
|
|
341
|
-
if (starter) recordStarter(id, starter);
|
|
370
|
+
recordStart(id, {
|
|
371
|
+
startedBy: opts.startedBy ?? null,
|
|
372
|
+
parentSessionId: opts.parentSessionId ?? null,
|
|
373
|
+
startedVia: opts.startedVia,
|
|
374
|
+
startedByDevice: opts.held?.device ?? opts.startedByDevice,
|
|
375
|
+
});
|
|
376
|
+
} catch (error) {
|
|
377
|
+
// The claim was made for a session that never existed.
|
|
378
|
+
if (opts.pieceId) releaseClaim(opts.pieceId, id);
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
342
381
|
if (opts.held) holdFor(id, opts.held.device, opts.held.orchestrated);
|
|
343
382
|
opts.onCreated?.(id);
|
|
344
383
|
void naming.refine?.(id);
|
package/lib/tasks/move-carry.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type Database from "better-sqlite3";
|
|
11
11
|
import { db } from "../db";
|
|
12
|
+
import { orchestratorOf } from "./starter";
|
|
12
13
|
|
|
13
14
|
// table, column, and how the id sits in it: the whole value, `task:<id>`, or
|
|
14
15
|
// the tail of a `<condition>:<id>` key.
|
|
@@ -59,9 +60,19 @@ export const CARRIED_COLUMNS = [
|
|
|
59
60
|
] as const;
|
|
60
61
|
|
|
61
62
|
/** The orchestration columns that make a session "an orchestrator's task". */
|
|
63
|
+
// started_by carries as the orchestrator only: a task a plain task started is
|
|
64
|
+
// not held, and a mirror reading it as held would relay to the wrong session.
|
|
62
65
|
export const orchestrationOf = (session: Record<string, unknown>) =>
|
|
63
66
|
Object.fromEntries(
|
|
64
|
-
CARRIED_COLUMNS.map(
|
|
67
|
+
CARRIED_COLUMNS.map(
|
|
68
|
+
(c) =>
|
|
69
|
+
[
|
|
70
|
+
c,
|
|
71
|
+
c === "started_by"
|
|
72
|
+
? orchestratorOf(session.started_by as string | null)
|
|
73
|
+
: (session[c] ?? null),
|
|
74
|
+
] as const
|
|
75
|
+
)
|
|
65
76
|
);
|
|
66
77
|
|
|
67
78
|
export function carryTaskState(
|
package/lib/tasks/move-guard.ts
CHANGED
|
@@ -10,10 +10,11 @@ import { db, type Session } from "../db";
|
|
|
10
10
|
import { escalations } from "../orchestrator/gates";
|
|
11
11
|
import { taskSubject } from "../orchestrator/asks";
|
|
12
12
|
import { usesGitCrypt } from "../worktree-add";
|
|
13
|
+
import { orchestratorOf } from "./starter";
|
|
13
14
|
|
|
14
15
|
/** Started by an orchestrator, or held for one by the machine it left. */
|
|
15
16
|
export function isOrchestrated(session: Session): boolean {
|
|
16
|
-
if (session.started_by || session.held_by_device) return true;
|
|
17
|
+
if (orchestratorOf(session.started_by) || session.held_by_device) return true;
|
|
17
18
|
return !!db
|
|
18
19
|
.prepare(
|
|
19
20
|
`SELECT 1 FROM orchestrator_starts WHERE kind != 'stack' AND target = ? LIMIT 1`
|
package/lib/tasks/move.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { dropSessionDatabase } from "../project-config/database";
|
|
|
30
30
|
import { releaseChat } from "../chat/runner";
|
|
31
31
|
import { collectWhileMoving } from "../chat/hold";
|
|
32
32
|
import { packChat, stopChatForMove } from "./move-chat";
|
|
33
|
+
import { orchestratorOf as starterOrchestrator } from "./starter";
|
|
33
34
|
import {
|
|
34
35
|
isClaudeSessionId,
|
|
35
36
|
latestSessionId,
|
|
@@ -122,11 +123,12 @@ function orchestratorOf(
|
|
|
122
123
|
): { id: string; name: string } | undefined {
|
|
123
124
|
if (session.held_for && session.held_for_name)
|
|
124
125
|
return { id: session.held_for, name: session.held_for_name };
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
const by = starterOrchestrator(session.started_by);
|
|
127
|
+
if (!by) return undefined;
|
|
128
|
+
const o = db.prepare(`SELECT name FROM sessions WHERE id = ?`).get(by) as
|
|
129
|
+
| { name: string }
|
|
130
|
+
| undefined;
|
|
131
|
+
return o ? { id: by, name: o.name } : undefined;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
// What an orchestrator's task carries besides its work (the orchestrator's
|
package/lib/tasks/pause.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { launchPending } from "./launch-gate";
|
|
|
28
28
|
import { killTmux } from "./start";
|
|
29
29
|
import { launchClaude } from "../agents/launch";
|
|
30
30
|
import { resolveRole, sessionWorkspace, taskOverride } from "../models/resolve";
|
|
31
|
-
import { sessionRole } from "../models/
|
|
31
|
+
import { sessionRole } from "../models/session-role";
|
|
32
32
|
import { holdingEscalations } from "../orchestrator/gates";
|
|
33
33
|
import { openBySubject, workSubject } from "../orchestrator/asks";
|
|
34
34
|
import { queueEventOnce } from "../orchestrator/events";
|