gentle-pi 2.7.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -6
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +14 -4
- package/docs/readme-reference.md +175 -36
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +67 -111
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +28 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +27 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/gentle-ai-installer.mjs +12 -12
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +307 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +849 -356
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-capability-contract.test.ts +30 -1
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +119 -31
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
package/lib/agents-runner.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-changes.ts";
|
|
3
2
|
import type { Duplex, Readable, Writable } from "node:stream";
|
|
4
3
|
import { stripVTControlCharacters } from "node:util";
|
|
5
|
-
import { RESEARCH_SELECTION_ENV
|
|
4
|
+
import { RESEARCH_SELECTION_ENV } from "./sdd-research-capabilities.ts";
|
|
6
5
|
import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
|
|
7
6
|
import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
|
|
8
7
|
import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
|
|
9
|
-
import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type
|
|
8
|
+
import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type TaskStore } from "./agents-protocol.ts";
|
|
10
9
|
|
|
11
10
|
// Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
|
|
12
11
|
// the host never runs subagent work on the TUI thread. It writes JSON
|
|
@@ -15,6 +14,7 @@ import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type
|
|
|
15
14
|
|
|
16
15
|
export interface ChildLike {
|
|
17
16
|
pid: number | undefined;
|
|
17
|
+
connected?: boolean;
|
|
18
18
|
stdin: Writable;
|
|
19
19
|
stdout: Readable;
|
|
20
20
|
stderr: Readable | null | undefined;
|
|
@@ -32,7 +32,7 @@ export interface SpawnOptions {
|
|
|
32
32
|
cwd: string;
|
|
33
33
|
env: NodeJS.ProcessEnv;
|
|
34
34
|
detached?: boolean;
|
|
35
|
-
stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc">;
|
|
35
|
+
stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc" | "overlapped">;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export type Spawn = (command: string, args: string[], options: SpawnOptions) => ChildLike;
|
|
@@ -58,6 +58,9 @@ export interface RunnerDeps {
|
|
|
58
58
|
export interface RunnerLimits {
|
|
59
59
|
maxConcurrency: number;
|
|
60
60
|
stallTimeoutMs: number;
|
|
61
|
+
// Longer ceiling used while an announced tool call is in flight. Optional so
|
|
62
|
+
// callers that only bound silence keep the idle budget as the tool ceiling.
|
|
63
|
+
toolStallTimeoutMs?: number;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
export interface AskAnswer {
|
|
@@ -114,21 +117,6 @@ export interface RemediationPlan {
|
|
|
114
117
|
runtimeHarness: RemediationHarnessPlan;
|
|
115
118
|
rollback: RemediationRollbackPlan;
|
|
116
119
|
}
|
|
117
|
-
export interface RemediationObservation {
|
|
118
|
-
slot: number;
|
|
119
|
-
toolCallId: string;
|
|
120
|
-
command: string;
|
|
121
|
-
cwd: string;
|
|
122
|
-
exitCode: number | null;
|
|
123
|
-
result: string;
|
|
124
|
-
}
|
|
125
|
-
export interface RemediationObservations {
|
|
126
|
-
failedEvidenceRevision: string;
|
|
127
|
-
plan: RemediationPlan;
|
|
128
|
-
observations: RemediationObservation[];
|
|
129
|
-
pending: Record<string, number>;
|
|
130
|
-
invalid: boolean;
|
|
131
|
-
}
|
|
132
120
|
const concrete = (value: unknown): value is string => typeof value === "string" && value.trim() === value && value.length > 3 && value.length <= 4096 && !/[\0\r\n]/.test(value);
|
|
133
121
|
export function parseRemediationPlan(value: unknown, cwd: string): RemediationPlan {
|
|
134
122
|
const plan = value as RemediationPlan;
|
|
@@ -138,65 +126,28 @@ export function parseRemediationPlan(value: unknown, cwd: string): RemediationPl
|
|
|
138
126
|
return structuredClone(plan);
|
|
139
127
|
}
|
|
140
128
|
export const plannedCommands = (plan: RemediationPlan) => [...plan.commands, ...(plan.runtimeHarness.command ? [plan.runtimeHarness.command] : []), plan.rollback.command];
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const id = raw.toolCallId;
|
|
147
|
-
if (raw.type === "tool_execution_start") {
|
|
148
|
-
const command = (raw.args as { command?: unknown } | undefined)?.command;
|
|
149
|
-
if (typeof command !== "string" || !plannedCommands(state.plan).includes(command)) return;
|
|
150
|
-
if (Object.hasOwn(state.pending, id) || state.observations.some(item => item.toolCallId === id) || Object.keys(state.pending).length >= 32) { state.invalid = true; return; }
|
|
151
|
-
const slot = plannedCommands(state.plan).findIndex((item, index) => item === command && !state.observations.some(observation => observation.slot === index) && !Object.values(state.pending).includes(index));
|
|
152
|
-
if (slot < 0) { state.invalid = true; return; }
|
|
153
|
-
state.pending[id] = slot;
|
|
154
|
-
}
|
|
155
|
-
if (raw.type !== "tool_execution_end" || !Object.hasOwn(state.pending, id)) return;
|
|
156
|
-
const slot = state.pending[id];
|
|
157
|
-
const command = plannedCommands(state.plan)[slot];
|
|
158
|
-
delete state.pending[id];
|
|
159
|
-
const result = raw.result as { content?: unknown; details?: { truncation?: unknown; fullOutputPath?: unknown; remediationCommand?: RemediationObservation & { truncated?: boolean } } } | undefined;
|
|
160
|
-
const observed = result?.details?.remediationCommand;
|
|
161
|
-
const output = JSON.stringify(result?.content ?? null);
|
|
162
|
-
if (!observed || observed.toolCallId !== id || observed.command !== command || observed.cwd !== state.plan.cwd ||
|
|
163
|
-
!(observed.exitCode === null || Number.isInteger(observed.exitCode)) || state.observations.length >= 32) { state.invalid = true; return; }
|
|
164
|
-
const contentValid = Array.isArray(result?.content) && result.content.length > 0 && result.content.every(part =>
|
|
165
|
-
part && typeof part === "object" && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string");
|
|
166
|
-
if (raw.isError !== false || observed.exitCode !== 0 || observed.truncated || result?.details?.truncation || result?.details?.fullOutputPath || output.length > 16_000 || !contentValid) state.invalid = true;
|
|
167
|
-
state.observations.push({ slot, toolCallId: id, command, cwd: observed.cwd, exitCode: observed.exitCode, result: `Observed command output ${evidenceDigest(output)}: ${output.slice(0, 400)}` });
|
|
168
|
-
}
|
|
169
|
-
export function remediationEvidence(state: RemediationObservations) {
|
|
170
|
-
const find = (slot: number) => state.observations.find(item => item.slot === slot && item.command === plannedCommands(state.plan)[slot] && item.cwd === state.plan.cwd && item.exitCode === 0);
|
|
171
|
-
if (state.invalid || Object.keys(state.pending).length || !/^sha256:[0-9a-f]{64}$/.test(state.failedEvidenceRevision) || !plannedCommands(state.plan).every((_, slot) => find(slot))) return undefined;
|
|
172
|
-
const result = (slot: number) => `cwd ${state.plan.cwd}; retained command observation ${evidenceDigest(JSON.stringify(find(slot)))}`;
|
|
173
|
-
return {
|
|
174
|
-
schema: "gentle-ai.remediation-evidence/v1",
|
|
175
|
-
failed_evidence_revision: state.failedEvidenceRevision,
|
|
176
|
-
commands: state.plan.commands.map((command, slot) => ({ command, exit_code: 0, result: result(slot) })),
|
|
177
|
-
runtime_harness: state.plan.runtimeHarness.command ? { status: "passed", command: state.plan.runtimeHarness.command, result: result(state.plan.commands.length) } : { status: "not_applicable", na_reason: state.plan.runtimeHarness.naReason },
|
|
178
|
-
rollback: { boundary: state.plan.rollback.boundary, evidence: result(plannedCommands(state.plan).length - 1) },
|
|
179
|
-
};
|
|
129
|
+
// Launch-local scope only; task history is not an attempt authority.
|
|
130
|
+
export interface RemediationContext {
|
|
131
|
+
failedEvidenceRevision: string;
|
|
132
|
+
plan: RemediationPlan;
|
|
133
|
+
scope: RemediationScope;
|
|
180
134
|
}
|
|
181
135
|
|
|
182
136
|
export interface SddChangeSelection {
|
|
183
137
|
changeName: string;
|
|
184
138
|
workspaceRoot: string;
|
|
185
|
-
phase: "apply" | "verify" | "
|
|
139
|
+
phase: "apply" | "verify" | "archive" | "remediate";
|
|
186
140
|
failedEvidenceRevision?: string;
|
|
187
141
|
}
|
|
188
142
|
|
|
189
143
|
export const SDD_CHANGE_FLAG = "--gentle-sdd-change";
|
|
190
144
|
|
|
191
|
-
export interface RemediationTerminalFacts { spawned: boolean; exited: boolean; cleanupConfirmed: boolean }
|
|
192
|
-
|
|
193
145
|
export const REMEDIATION_PLAN_ENV = "GENTLE_PI_SDD_REMEDIATION_PLAN";
|
|
194
146
|
|
|
195
147
|
export interface TaskRequest {
|
|
196
148
|
remediationIntent?: unknown;
|
|
197
|
-
sddRemediation?:
|
|
149
|
+
sddRemediation?: RemediationContext;
|
|
198
150
|
sddPreflightContext?: string;
|
|
199
|
-
finalizeRemediation?: (task: TaskRecord, facts: RemediationTerminalFacts) => Promise<void>;
|
|
200
151
|
agent: AgentDefinition;
|
|
201
152
|
prompt: string;
|
|
202
153
|
label: string | undefined;
|
|
@@ -213,7 +164,6 @@ export interface TaskRequest {
|
|
|
213
164
|
sddChange?: SddChangeSelection;
|
|
214
165
|
// Untrusted narrowing intent; paths come only from matching host provenance.
|
|
215
166
|
researchSelection?: unknown;
|
|
216
|
-
researchArtifact?: ResearchArtifactIntent;
|
|
217
167
|
extensionPaths?: string[];
|
|
218
168
|
// Captures the originating session; invoked only after successful OS spawn.
|
|
219
169
|
onLaunch?: () => void;
|
|
@@ -258,6 +208,7 @@ interface PendingReply {
|
|
|
258
208
|
|
|
259
209
|
interface LiveTask {
|
|
260
210
|
child: ChildLike;
|
|
211
|
+
sawRunEvent: boolean;
|
|
261
212
|
observations?: ChildObservationBuffer;
|
|
262
213
|
observationGuard?: () => boolean;
|
|
263
214
|
observationPreparation?: () => boolean;
|
|
@@ -277,6 +228,10 @@ interface LiveTask {
|
|
|
277
228
|
acknowledgedIpcIds: Set<string>;
|
|
278
229
|
acknowledgedIpcOrder: string[];
|
|
279
230
|
mutationStarts: Map<string, { toolName: "write" | "edit"; toolCallId: string; path: string }>;
|
|
231
|
+
// Tool calls the child announced and has not ended yet. A call in flight is
|
|
232
|
+
// live work, so the watchdog gives it the tool ceiling instead of the idle
|
|
233
|
+
// silence budget. Keyed by call id, holding the announced tool name.
|
|
234
|
+
inFlightTools: Map<string, string>;
|
|
280
235
|
// Bounded ring buffer of the child's raw stderr output, capped to the last
|
|
281
236
|
// STDERR_TAIL_MAX characters. Only surfaced on the stall and pre-settle exit
|
|
282
237
|
// terminal paths, never on completed, cancelled, or other failure reasons.
|
|
@@ -327,7 +282,7 @@ export function childArguments(request: TaskRequest): string[] {
|
|
|
327
282
|
if (request.resumeSessionPath) args.push("--session", request.resumeSessionPath);
|
|
328
283
|
if (request.model) args.push("--model", request.thinking ? `${formatModelRef(request.model)}:${request.thinking}` : formatModelRef(request.model));
|
|
329
284
|
else if (request.thinking) args.push("--thinking", request.thinking);
|
|
330
|
-
const tools = request.agent.tools.length > 0 ? [...new Set([...request.agent.tools, PARENT_NOTIFICATION_TOOL])] : DEFAULT_TOOLS;
|
|
285
|
+
const tools = request.agent.tools.length > 0 || request.agent.name === "sdd-research" ? [...new Set([...request.agent.tools, PARENT_NOTIFICATION_TOOL])] : DEFAULT_TOOLS;
|
|
331
286
|
if (tools.length > 0) args.push("--tools", tools.join(","));
|
|
332
287
|
if (request.agent.instructions.length > 0) args.push("--append-system-prompt", request.agent.instructions);
|
|
333
288
|
return args;
|
|
@@ -374,7 +329,7 @@ export class JsonLines {
|
|
|
374
329
|
|
|
375
330
|
export function promptText(request: TaskRequest): string {
|
|
376
331
|
const prompt = request.context ? `${request.prompt}\n\n## Context\n${request.context}` : request.prompt;
|
|
377
|
-
return request.sddRemediation ? `${prompt}\n\n##
|
|
332
|
+
return request.sddRemediation ? `${prompt}\n\n## Human-authorized remediation plan\nExecute only these exact commands in the selected cwd; report actual results without claiming native verification.\n${JSON.stringify(request.sddRemediation.plan)}\nFailed evidence: ${request.sddRemediation.failedEvidenceRevision}` : prompt;
|
|
378
333
|
}
|
|
379
334
|
|
|
380
335
|
export class AgentRunner {
|
|
@@ -385,8 +340,6 @@ export class AgentRunner {
|
|
|
385
340
|
private readonly processControl: ProcessControl;
|
|
386
341
|
private readonly queue: Array<{ task: TaskRecord; request: TaskRequest }> = [];
|
|
387
342
|
private readonly live = new Map<string, LiveTask>();
|
|
388
|
-
private readonly remediationFinalizers = new Map<string, NonNullable<TaskRequest["finalizeRemediation"]>>();
|
|
389
|
-
private readonly finalizingRemediation = new Set<string>();
|
|
390
343
|
private readonly waiters = new Map<string, Array<(task: TaskRecord) => void>>();
|
|
391
344
|
private readonly queryWaiters = new Map<string, Array<(query: TaskQuery | undefined) => void>>();
|
|
392
345
|
private readonly firstQueries = new Map<string, TaskQuery>();
|
|
@@ -400,22 +353,15 @@ export class AgentRunner {
|
|
|
400
353
|
this.processControl = deps.process ?? hostProcess;
|
|
401
354
|
}
|
|
402
355
|
|
|
403
|
-
prepareRemediation(request: TaskRequest): TaskRecord {
|
|
404
|
-
if (request.agent.name !== "sdd-remediate" || this.store.list().some(task => task.agent === "sdd-remediate" && task.cwd === request.cwd && !isFinished(task.status))) throw new Error("Remediation already preparing/running; reconcile its retained task before another actor");
|
|
405
|
-
return this.createTask(request);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
356
|
private createTask(request: TaskRequest): TaskRecord {
|
|
409
357
|
const now = this.deps.now();
|
|
410
358
|
this.counter += 1;
|
|
411
359
|
const task: TaskRecord = {
|
|
412
360
|
id: `${now.toString(36)}-${this.counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
413
361
|
agent: request.agent.name,
|
|
414
|
-
...(request.sddRemediation ? { sddRemediation: structuredClone(request.sddRemediation) } : {}),
|
|
415
362
|
...(request.sddPreflightContext ? { sddPreflightContext: request.sddPreflightContext } : {}),
|
|
416
363
|
mode: request.mode,
|
|
417
364
|
prompt: request.prompt,
|
|
418
|
-
...(request.researchArtifact ? { researchArtifact: structuredClone(request.researchArtifact) } : {}),
|
|
419
365
|
label: taskLabel(request.prompt, request.label),
|
|
420
366
|
cwd: request.cwd,
|
|
421
367
|
parentSessionId: request.parentSessionId,
|
|
@@ -439,17 +385,22 @@ export class AgentRunner {
|
|
|
439
385
|
return task;
|
|
440
386
|
}
|
|
441
387
|
|
|
442
|
-
run(request: TaskRequest
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (request.
|
|
388
|
+
run(request: TaskRequest): TaskRecord {
|
|
389
|
+
// Admission already confirmed the canonical cwd and human edit scope.
|
|
390
|
+
// Check this runner's queue/live slots before scheduling any launch: history
|
|
391
|
+
// is not a lock, and quarantined children still own their live slot.
|
|
392
|
+
if (request.sddRemediation) {
|
|
393
|
+
const active = [...this.queue.map(entry => entry.task), ...[...this.live.keys()].map(id => this.store.get(id))];
|
|
394
|
+
if (active.some(task => task?.agent === "sdd-remediate" && task.cwd === request.cwd)) {
|
|
395
|
+
throw new Error("Remediation already queued or running in this worktree; wait for confirmed cleanup or cancel the active task before requesting fresh authorization");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const task = this.createTask(request);
|
|
447
399
|
// A caller can retain and mutate its request after dispatch. Preserve only
|
|
448
400
|
// the identity selected at construction for this child launch.
|
|
449
401
|
const launchRequest = {
|
|
450
402
|
...request,
|
|
451
403
|
sddChange: request.sddChange && { ...request.sddChange },
|
|
452
|
-
researchArtifact: request.researchArtifact && structuredClone(request.researchArtifact),
|
|
453
404
|
};
|
|
454
405
|
this.queue.push({ task, request: launchRequest });
|
|
455
406
|
queueMicrotask(() => this.pump());
|
|
@@ -539,9 +490,10 @@ export class AgentRunner {
|
|
|
539
490
|
private launch(id: string, request: TaskRequest): void {
|
|
540
491
|
const detached = this.processControl.platform !== "win32";
|
|
541
492
|
const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
|
|
493
|
+
const permissionChannelStdio = this.processControl.platform === "win32" ? "overlapped" : "pipe";
|
|
542
494
|
const env = {
|
|
543
495
|
...request.env,
|
|
544
|
-
...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null)
|
|
496
|
+
...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null) } : {}),
|
|
545
497
|
[CHILD_MARKER]: "1",
|
|
546
498
|
[IPC_MARKER]: `${this.deps.now()}-${Math.random().toString(36).slice(2)}`,
|
|
547
499
|
...(hasParentPermissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}),
|
|
@@ -554,7 +506,7 @@ export class AgentRunner {
|
|
|
554
506
|
cwd: request.cwd,
|
|
555
507
|
env,
|
|
556
508
|
detached,
|
|
557
|
-
stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe",
|
|
509
|
+
stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
|
|
558
510
|
});
|
|
559
511
|
} catch (error) {
|
|
560
512
|
this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
|
|
@@ -562,7 +514,7 @@ export class AgentRunner {
|
|
|
562
514
|
return;
|
|
563
515
|
}
|
|
564
516
|
const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
|
|
565
|
-
const live: LiveTask = { child, mutationStarts: new Map(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [], stderrTail: "" };
|
|
517
|
+
const live: LiveTask = { child, sawRunEvent: false, mutationStarts: new Map(), inFlightTools: new Map(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [], stderrTail: "" };
|
|
566
518
|
if (request.prepareResponseObservations) {
|
|
567
519
|
let ready = false;
|
|
568
520
|
live.observationPreparation = () => ready;
|
|
@@ -645,13 +597,23 @@ export class AgentRunner {
|
|
|
645
597
|
return cleaned ? `; stderr: ${cleaned}` : "";
|
|
646
598
|
}
|
|
647
599
|
|
|
600
|
+
// An idle child is bounded by the silence budget; a child whose announced
|
|
601
|
+
// tool call is still running is live work and bounded by the longer tool
|
|
602
|
+
// ceiling. The budget is chosen from the state at arm time, and every RPC
|
|
603
|
+
// object re-arms, so a finished tool call returns the task to idle silence.
|
|
648
604
|
private armStall(id: string, live: LiveTask): void {
|
|
649
605
|
live.cancelStall();
|
|
606
|
+
const tool = live.inFlightTools.values().next().value;
|
|
607
|
+
const budget = tool === undefined ? this.limits.stallTimeoutMs : Math.max(this.limits.toolStallTimeoutMs ?? this.limits.stallTimeoutMs, this.limits.stallTimeoutMs);
|
|
650
608
|
live.cancelStall = this.deps.schedule(() => {
|
|
651
609
|
const lastStep = this.store.get(id)?.lastStep ?? "starting";
|
|
652
|
-
const minutes = Math.round(
|
|
653
|
-
|
|
654
|
-
|
|
610
|
+
const minutes = Math.round(budget / 60_000);
|
|
611
|
+
if (tool === undefined) {
|
|
612
|
+
const boundary = lastStep === "prompt accepted" && !live.sawRunEvent ? `; no first run event received for model: ${this.store.get(id)?.model}` : "";
|
|
613
|
+
this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${minutes} min after: ${lastStep}${boundary}${this.stderrSuffix(live)}`);
|
|
614
|
+
}
|
|
615
|
+
else this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${minutes} min with tool "${tool}" still running after: ${lastStep}${this.stderrSuffix(live)}`);
|
|
616
|
+
}, budget);
|
|
655
617
|
}
|
|
656
618
|
|
|
657
619
|
private send(id: string, command: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
@@ -759,8 +721,10 @@ export class AgentRunner {
|
|
|
759
721
|
for (const pending of live.replies.values()) pending.resolve(false);
|
|
760
722
|
live.replies.clear();
|
|
761
723
|
live.child.channel?.unref?.();
|
|
762
|
-
|
|
763
|
-
|
|
724
|
+
if (live.child.connected !== false) {
|
|
725
|
+
try { live.child.disconnect?.(); }
|
|
726
|
+
catch { /* Channel may already be disconnected. */ }
|
|
727
|
+
}
|
|
764
728
|
}
|
|
765
729
|
|
|
766
730
|
private write(live: LiveTask, payload: Record<string, unknown>): void {
|
|
@@ -788,10 +752,8 @@ export class AgentRunner {
|
|
|
788
752
|
const live = this.live.get(id);
|
|
789
753
|
if (!live || live.terminal || !value || typeof value !== "object") return;
|
|
790
754
|
const raw = value as Record<string, unknown>;
|
|
791
|
-
const remediation = this.store.get(id)?.sddRemediation;
|
|
792
|
-
if (remediation) observeRemediationTool(remediation, raw);
|
|
793
|
-
this.armStall(id, live);
|
|
794
755
|
if (raw.type === "response") {
|
|
756
|
+
this.armStall(id, live);
|
|
795
757
|
if (!live.observationPreparation) this.checkObservationGrant(live);
|
|
796
758
|
const pending = typeof raw.id === "string" ? live.pending.get(raw.id) : undefined;
|
|
797
759
|
if (pending) {
|
|
@@ -801,7 +763,13 @@ export class AgentRunner {
|
|
|
801
763
|
return;
|
|
802
764
|
}
|
|
803
765
|
this.checkObservationGrant(live);
|
|
804
|
-
|
|
766
|
+
const events = normalizeRpcEvent(raw, { observeResponses: live.observations !== undefined });
|
|
767
|
+
// A parsed object is not progress by itself. Only a recognized run event
|
|
768
|
+
// renews the watchdog here, so fire-and-forget UI traffic that normalizes
|
|
769
|
+
// to nothing cannot keep a child that never started its run alive forever
|
|
770
|
+
// (#1034); the pre-existing timer stays armed until real progress arrives.
|
|
771
|
+
const progress = events.length > 0;
|
|
772
|
+
for (const event of events) {
|
|
805
773
|
if (event.type === TASK_EVENT.RESPONSE_OBSERVATION) {
|
|
806
774
|
const buffer = live.observations;
|
|
807
775
|
if (buffer) {
|
|
@@ -810,14 +778,17 @@ export class AgentRunner {
|
|
|
810
778
|
}
|
|
811
779
|
continue; // Separate from store persistence, UI totals and notifications.
|
|
812
780
|
}
|
|
781
|
+
live.sawRunEvent = true;
|
|
813
782
|
this.store.apply(id, event, this.deps.now());
|
|
814
783
|
if (event.type === TASK_EVENT.TOOL_START && event.callId) {
|
|
784
|
+
live.inFlightTools.set(event.callId, event.name);
|
|
815
785
|
live.mutationStarts.delete(event.callId);
|
|
816
786
|
if ((event.name === "write" || event.name === "edit") && typeof event.args.path === "string" && event.args.path.trim()) {
|
|
817
787
|
live.mutationStarts.set(event.callId, { toolName: event.name, toolCallId: event.callId, path: event.args.path });
|
|
818
788
|
}
|
|
819
789
|
}
|
|
820
790
|
if (event.type === TASK_EVENT.TOOL_END) {
|
|
791
|
+
live.inFlightTools.delete(event.callId);
|
|
821
792
|
const mutation = live.mutationStarts.get(event.callId);
|
|
822
793
|
live.mutationStarts.delete(event.callId);
|
|
823
794
|
const task = this.store.get(id);
|
|
@@ -839,6 +810,7 @@ export class AgentRunner {
|
|
|
839
810
|
else this.requestStop(id, TASK_STATUS.FAILED, "assistant settled without a final report");
|
|
840
811
|
}
|
|
841
812
|
}
|
|
813
|
+
if (!live.terminal && progress) this.armStall(id, live);
|
|
842
814
|
}
|
|
843
815
|
|
|
844
816
|
// Task-mode subagents may ask the human through the host; background ones
|
|
@@ -881,6 +853,7 @@ export class AgentRunner {
|
|
|
881
853
|
if (!live || live.terminal) return;
|
|
882
854
|
live.terminal = { status, error };
|
|
883
855
|
live.mutationStarts.clear();
|
|
856
|
+
live.inFlightTools.clear();
|
|
884
857
|
live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
|
|
885
858
|
live.permissionBroker?.close();
|
|
886
859
|
this.closeIpc(live);
|
|
@@ -994,24 +967,7 @@ export class AgentRunner {
|
|
|
994
967
|
|
|
995
968
|
private finish(id: string, status: TaskRecord["status"], error: string | null, live?: LiveTask): void {
|
|
996
969
|
const current = this.store.get(id);
|
|
997
|
-
if (!current || isFinished(current.status)
|
|
998
|
-
const finalize = this.remediationFinalizers.get(id);
|
|
999
|
-
if (finalize) {
|
|
1000
|
-
this.finalizingRemediation.add(id);
|
|
1001
|
-
const terminal = { ...current, status, error };
|
|
1002
|
-
void finalize(terminal, { spawned: typeof live?.child.pid === "number", exited: live?.childExit !== undefined, cleanupConfirmed: !live || !live.quarantined && live.childExit !== undefined }).then(() => {
|
|
1003
|
-
status = terminal.status;
|
|
1004
|
-
error = terminal.error;
|
|
1005
|
-
}).catch(() => {
|
|
1006
|
-
status = TASK_STATUS.FAILED;
|
|
1007
|
-
error = "Native remediation settlement could not be durably finalized; retain task history and reconcile before further execution";
|
|
1008
|
-
}).finally(() => {
|
|
1009
|
-
this.remediationFinalizers.delete(id);
|
|
1010
|
-
this.finalizingRemediation.delete(id);
|
|
1011
|
-
this.finish(id, status, error, live);
|
|
1012
|
-
});
|
|
1013
|
-
return;
|
|
1014
|
-
}
|
|
970
|
+
if (!current || isFinished(current.status)) return;
|
|
1015
971
|
const finished = this.store.update(id, { status, endedAt: this.deps.now(), error, lastStep: error ?? "done" });
|
|
1016
972
|
if (finished) {
|
|
1017
973
|
if (live) this.checkObservationGrant(live);
|