gentle-pi 2.6.4 → 3.0.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 +25 -7
- 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 +41 -19
- package/docs/readme-reference.md +175 -36
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +289 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +123 -97
- 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 +74 -113
- 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 +19 -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 +88 -0
- package/lib/session-changes.ts +147 -0
- package/lib/shell-bar.ts +24 -10
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-changes.ts +5 -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 +18 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- 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 +321 -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 +883 -356
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +338 -205
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-capability-contract.test.ts +15 -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 +86 -0
- package/tests/session-changes-shell.test.ts +38 -0
- package/tests/session-changes.test.ts +114 -0
- package/tests/shell-bar.test.ts +35 -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,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-changes.ts";
|
|
2
2
|
import type { Duplex, Readable, Writable } from "node:stream";
|
|
3
3
|
import { stripVTControlCharacters } from "node:util";
|
|
4
|
-
import { RESEARCH_SELECTION_ENV
|
|
4
|
+
import { RESEARCH_SELECTION_ENV } from "./sdd-research-capabilities.ts";
|
|
5
5
|
import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
|
|
6
6
|
import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
|
|
7
7
|
import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
|
|
8
|
-
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";
|
|
9
9
|
|
|
10
10
|
// Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
|
|
11
11
|
// the host never runs subagent work on the TUI thread. It writes JSON
|
|
@@ -14,6 +14,7 @@ import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type
|
|
|
14
14
|
|
|
15
15
|
export interface ChildLike {
|
|
16
16
|
pid: number | undefined;
|
|
17
|
+
connected?: boolean;
|
|
17
18
|
stdin: Writable;
|
|
18
19
|
stdout: Readable;
|
|
19
20
|
stderr: Readable | null | undefined;
|
|
@@ -31,7 +32,7 @@ export interface SpawnOptions {
|
|
|
31
32
|
cwd: string;
|
|
32
33
|
env: NodeJS.ProcessEnv;
|
|
33
34
|
detached?: boolean;
|
|
34
|
-
stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc">;
|
|
35
|
+
stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc" | "overlapped">;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export type Spawn = (command: string, args: string[], options: SpawnOptions) => ChildLike;
|
|
@@ -57,6 +58,9 @@ export interface RunnerDeps {
|
|
|
57
58
|
export interface RunnerLimits {
|
|
58
59
|
maxConcurrency: number;
|
|
59
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;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export interface AskAnswer {
|
|
@@ -100,7 +104,7 @@ export interface RunnerHooks {
|
|
|
100
104
|
onNotification?(task: TaskRecord, message: string): boolean | void;
|
|
101
105
|
onQuery?(task: TaskRecord, requestId: string, message: string): boolean | void;
|
|
102
106
|
// Parent-only observation of a paired successful filesystem tool, not prose.
|
|
103
|
-
onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string }): void | Promise<void>;
|
|
107
|
+
onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string; evidence?: SessionChangeEvidence }): void | Promise<void>;
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
export interface RemediationHarnessPlan { command?: string; naReason?: string }
|
|
@@ -113,21 +117,6 @@ export interface RemediationPlan {
|
|
|
113
117
|
runtimeHarness: RemediationHarnessPlan;
|
|
114
118
|
rollback: RemediationRollbackPlan;
|
|
115
119
|
}
|
|
116
|
-
export interface RemediationObservation {
|
|
117
|
-
slot: number;
|
|
118
|
-
toolCallId: string;
|
|
119
|
-
command: string;
|
|
120
|
-
cwd: string;
|
|
121
|
-
exitCode: number | null;
|
|
122
|
-
result: string;
|
|
123
|
-
}
|
|
124
|
-
export interface RemediationObservations {
|
|
125
|
-
failedEvidenceRevision: string;
|
|
126
|
-
plan: RemediationPlan;
|
|
127
|
-
observations: RemediationObservation[];
|
|
128
|
-
pending: Record<string, number>;
|
|
129
|
-
invalid: boolean;
|
|
130
|
-
}
|
|
131
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);
|
|
132
121
|
export function parseRemediationPlan(value: unknown, cwd: string): RemediationPlan {
|
|
133
122
|
const plan = value as RemediationPlan;
|
|
@@ -137,65 +126,28 @@ export function parseRemediationPlan(value: unknown, cwd: string): RemediationPl
|
|
|
137
126
|
return structuredClone(plan);
|
|
138
127
|
}
|
|
139
128
|
export const plannedCommands = (plan: RemediationPlan) => [...plan.commands, ...(plan.runtimeHarness.command ? [plan.runtimeHarness.command] : []), plan.rollback.command];
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const id = raw.toolCallId;
|
|
146
|
-
if (raw.type === "tool_execution_start") {
|
|
147
|
-
const command = (raw.args as { command?: unknown } | undefined)?.command;
|
|
148
|
-
if (typeof command !== "string" || !plannedCommands(state.plan).includes(command)) return;
|
|
149
|
-
if (Object.hasOwn(state.pending, id) || state.observations.some(item => item.toolCallId === id) || Object.keys(state.pending).length >= 32) { state.invalid = true; return; }
|
|
150
|
-
const slot = plannedCommands(state.plan).findIndex((item, index) => item === command && !state.observations.some(observation => observation.slot === index) && !Object.values(state.pending).includes(index));
|
|
151
|
-
if (slot < 0) { state.invalid = true; return; }
|
|
152
|
-
state.pending[id] = slot;
|
|
153
|
-
}
|
|
154
|
-
if (raw.type !== "tool_execution_end" || !Object.hasOwn(state.pending, id)) return;
|
|
155
|
-
const slot = state.pending[id];
|
|
156
|
-
const command = plannedCommands(state.plan)[slot];
|
|
157
|
-
delete state.pending[id];
|
|
158
|
-
const result = raw.result as { content?: unknown; details?: { truncation?: unknown; fullOutputPath?: unknown; remediationCommand?: RemediationObservation & { truncated?: boolean } } } | undefined;
|
|
159
|
-
const observed = result?.details?.remediationCommand;
|
|
160
|
-
const output = JSON.stringify(result?.content ?? null);
|
|
161
|
-
if (!observed || observed.toolCallId !== id || observed.command !== command || observed.cwd !== state.plan.cwd ||
|
|
162
|
-
!(observed.exitCode === null || Number.isInteger(observed.exitCode)) || state.observations.length >= 32) { state.invalid = true; return; }
|
|
163
|
-
const contentValid = Array.isArray(result?.content) && result.content.length > 0 && result.content.every(part =>
|
|
164
|
-
part && typeof part === "object" && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string");
|
|
165
|
-
if (raw.isError !== false || observed.exitCode !== 0 || observed.truncated || result?.details?.truncation || result?.details?.fullOutputPath || output.length > 16_000 || !contentValid) state.invalid = true;
|
|
166
|
-
state.observations.push({ slot, toolCallId: id, command, cwd: observed.cwd, exitCode: observed.exitCode, result: `Observed command output ${evidenceDigest(output)}: ${output.slice(0, 400)}` });
|
|
167
|
-
}
|
|
168
|
-
export function remediationEvidence(state: RemediationObservations) {
|
|
169
|
-
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);
|
|
170
|
-
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;
|
|
171
|
-
const result = (slot: number) => `cwd ${state.plan.cwd}; retained command observation ${evidenceDigest(JSON.stringify(find(slot)))}`;
|
|
172
|
-
return {
|
|
173
|
-
schema: "gentle-ai.remediation-evidence/v1",
|
|
174
|
-
failed_evidence_revision: state.failedEvidenceRevision,
|
|
175
|
-
commands: state.plan.commands.map((command, slot) => ({ command, exit_code: 0, result: result(slot) })),
|
|
176
|
-
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 },
|
|
177
|
-
rollback: { boundary: state.plan.rollback.boundary, evidence: result(plannedCommands(state.plan).length - 1) },
|
|
178
|
-
};
|
|
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;
|
|
179
134
|
}
|
|
180
135
|
|
|
181
136
|
export interface SddChangeSelection {
|
|
182
137
|
changeName: string;
|
|
183
138
|
workspaceRoot: string;
|
|
184
|
-
phase: "apply" | "verify" | "
|
|
139
|
+
phase: "apply" | "verify" | "archive" | "remediate";
|
|
185
140
|
failedEvidenceRevision?: string;
|
|
186
141
|
}
|
|
187
142
|
|
|
188
143
|
export const SDD_CHANGE_FLAG = "--gentle-sdd-change";
|
|
189
144
|
|
|
190
|
-
export interface RemediationTerminalFacts { spawned: boolean; exited: boolean; cleanupConfirmed: boolean }
|
|
191
|
-
|
|
192
145
|
export const REMEDIATION_PLAN_ENV = "GENTLE_PI_SDD_REMEDIATION_PLAN";
|
|
193
146
|
|
|
194
147
|
export interface TaskRequest {
|
|
195
148
|
remediationIntent?: unknown;
|
|
196
|
-
sddRemediation?:
|
|
149
|
+
sddRemediation?: RemediationContext;
|
|
197
150
|
sddPreflightContext?: string;
|
|
198
|
-
finalizeRemediation?: (task: TaskRecord, facts: RemediationTerminalFacts) => Promise<void>;
|
|
199
151
|
agent: AgentDefinition;
|
|
200
152
|
prompt: string;
|
|
201
153
|
label: string | undefined;
|
|
@@ -212,7 +164,6 @@ export interface TaskRequest {
|
|
|
212
164
|
sddChange?: SddChangeSelection;
|
|
213
165
|
// Untrusted narrowing intent; paths come only from matching host provenance.
|
|
214
166
|
researchSelection?: unknown;
|
|
215
|
-
researchArtifact?: ResearchArtifactIntent;
|
|
216
167
|
extensionPaths?: string[];
|
|
217
168
|
// Captures the originating session; invoked only after successful OS spawn.
|
|
218
169
|
onLaunch?: () => void;
|
|
@@ -257,6 +208,7 @@ interface PendingReply {
|
|
|
257
208
|
|
|
258
209
|
interface LiveTask {
|
|
259
210
|
child: ChildLike;
|
|
211
|
+
sawRunEvent: boolean;
|
|
260
212
|
observations?: ChildObservationBuffer;
|
|
261
213
|
observationGuard?: () => boolean;
|
|
262
214
|
observationPreparation?: () => boolean;
|
|
@@ -276,6 +228,10 @@ interface LiveTask {
|
|
|
276
228
|
acknowledgedIpcIds: Set<string>;
|
|
277
229
|
acknowledgedIpcOrder: string[];
|
|
278
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>;
|
|
279
235
|
// Bounded ring buffer of the child's raw stderr output, capped to the last
|
|
280
236
|
// STDERR_TAIL_MAX characters. Only surfaced on the stall and pre-settle exit
|
|
281
237
|
// terminal paths, never on completed, cancelled, or other failure reasons.
|
|
@@ -326,7 +282,7 @@ export function childArguments(request: TaskRequest): string[] {
|
|
|
326
282
|
if (request.resumeSessionPath) args.push("--session", request.resumeSessionPath);
|
|
327
283
|
if (request.model) args.push("--model", request.thinking ? `${formatModelRef(request.model)}:${request.thinking}` : formatModelRef(request.model));
|
|
328
284
|
else if (request.thinking) args.push("--thinking", request.thinking);
|
|
329
|
-
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;
|
|
330
286
|
if (tools.length > 0) args.push("--tools", tools.join(","));
|
|
331
287
|
if (request.agent.instructions.length > 0) args.push("--append-system-prompt", request.agent.instructions);
|
|
332
288
|
return args;
|
|
@@ -373,7 +329,7 @@ export class JsonLines {
|
|
|
373
329
|
|
|
374
330
|
export function promptText(request: TaskRequest): string {
|
|
375
331
|
const prompt = request.context ? `${request.prompt}\n\n## Context\n${request.context}` : request.prompt;
|
|
376
|
-
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;
|
|
377
333
|
}
|
|
378
334
|
|
|
379
335
|
export class AgentRunner {
|
|
@@ -384,8 +340,6 @@ export class AgentRunner {
|
|
|
384
340
|
private readonly processControl: ProcessControl;
|
|
385
341
|
private readonly queue: Array<{ task: TaskRecord; request: TaskRequest }> = [];
|
|
386
342
|
private readonly live = new Map<string, LiveTask>();
|
|
387
|
-
private readonly remediationFinalizers = new Map<string, NonNullable<TaskRequest["finalizeRemediation"]>>();
|
|
388
|
-
private readonly finalizingRemediation = new Set<string>();
|
|
389
343
|
private readonly waiters = new Map<string, Array<(task: TaskRecord) => void>>();
|
|
390
344
|
private readonly queryWaiters = new Map<string, Array<(query: TaskQuery | undefined) => void>>();
|
|
391
345
|
private readonly firstQueries = new Map<string, TaskQuery>();
|
|
@@ -399,22 +353,15 @@ export class AgentRunner {
|
|
|
399
353
|
this.processControl = deps.process ?? hostProcess;
|
|
400
354
|
}
|
|
401
355
|
|
|
402
|
-
prepareRemediation(request: TaskRequest): TaskRecord {
|
|
403
|
-
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");
|
|
404
|
-
return this.createTask(request);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
356
|
private createTask(request: TaskRequest): TaskRecord {
|
|
408
357
|
const now = this.deps.now();
|
|
409
358
|
this.counter += 1;
|
|
410
359
|
const task: TaskRecord = {
|
|
411
360
|
id: `${now.toString(36)}-${this.counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
412
361
|
agent: request.agent.name,
|
|
413
|
-
...(request.sddRemediation ? { sddRemediation: structuredClone(request.sddRemediation) } : {}),
|
|
414
362
|
...(request.sddPreflightContext ? { sddPreflightContext: request.sddPreflightContext } : {}),
|
|
415
363
|
mode: request.mode,
|
|
416
364
|
prompt: request.prompt,
|
|
417
|
-
...(request.researchArtifact ? { researchArtifact: structuredClone(request.researchArtifact) } : {}),
|
|
418
365
|
label: taskLabel(request.prompt, request.label),
|
|
419
366
|
cwd: request.cwd,
|
|
420
367
|
parentSessionId: request.parentSessionId,
|
|
@@ -438,17 +385,22 @@ export class AgentRunner {
|
|
|
438
385
|
return task;
|
|
439
386
|
}
|
|
440
387
|
|
|
441
|
-
run(request: TaskRequest
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
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);
|
|
446
399
|
// A caller can retain and mutate its request after dispatch. Preserve only
|
|
447
400
|
// the identity selected at construction for this child launch.
|
|
448
401
|
const launchRequest = {
|
|
449
402
|
...request,
|
|
450
403
|
sddChange: request.sddChange && { ...request.sddChange },
|
|
451
|
-
researchArtifact: request.researchArtifact && structuredClone(request.researchArtifact),
|
|
452
404
|
};
|
|
453
405
|
this.queue.push({ task, request: launchRequest });
|
|
454
406
|
queueMicrotask(() => this.pump());
|
|
@@ -538,9 +490,10 @@ export class AgentRunner {
|
|
|
538
490
|
private launch(id: string, request: TaskRequest): void {
|
|
539
491
|
const detached = this.processControl.platform !== "win32";
|
|
540
492
|
const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
|
|
493
|
+
const permissionChannelStdio = this.processControl.platform === "win32" ? "overlapped" : "pipe";
|
|
541
494
|
const env = {
|
|
542
495
|
...request.env,
|
|
543
|
-
...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null)
|
|
496
|
+
...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null) } : {}),
|
|
544
497
|
[CHILD_MARKER]: "1",
|
|
545
498
|
[IPC_MARKER]: `${this.deps.now()}-${Math.random().toString(36).slice(2)}`,
|
|
546
499
|
...(hasParentPermissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}),
|
|
@@ -553,7 +506,7 @@ export class AgentRunner {
|
|
|
553
506
|
cwd: request.cwd,
|
|
554
507
|
env,
|
|
555
508
|
detached,
|
|
556
|
-
stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe",
|
|
509
|
+
stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
|
|
557
510
|
});
|
|
558
511
|
} catch (error) {
|
|
559
512
|
this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
|
|
@@ -561,7 +514,7 @@ export class AgentRunner {
|
|
|
561
514
|
return;
|
|
562
515
|
}
|
|
563
516
|
const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
|
|
564
|
-
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: "" };
|
|
565
518
|
if (request.prepareResponseObservations) {
|
|
566
519
|
let ready = false;
|
|
567
520
|
live.observationPreparation = () => ready;
|
|
@@ -644,13 +597,23 @@ export class AgentRunner {
|
|
|
644
597
|
return cleaned ? `; stderr: ${cleaned}` : "";
|
|
645
598
|
}
|
|
646
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.
|
|
647
604
|
private armStall(id: string, live: LiveTask): void {
|
|
648
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);
|
|
649
608
|
live.cancelStall = this.deps.schedule(() => {
|
|
650
609
|
const lastStep = this.store.get(id)?.lastStep ?? "starting";
|
|
651
|
-
const minutes = Math.round(
|
|
652
|
-
|
|
653
|
-
|
|
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);
|
|
654
617
|
}
|
|
655
618
|
|
|
656
619
|
private send(id: string, command: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
@@ -758,8 +721,10 @@ export class AgentRunner {
|
|
|
758
721
|
for (const pending of live.replies.values()) pending.resolve(false);
|
|
759
722
|
live.replies.clear();
|
|
760
723
|
live.child.channel?.unref?.();
|
|
761
|
-
|
|
762
|
-
|
|
724
|
+
if (live.child.connected !== false) {
|
|
725
|
+
try { live.child.disconnect?.(); }
|
|
726
|
+
catch { /* Channel may already be disconnected. */ }
|
|
727
|
+
}
|
|
763
728
|
}
|
|
764
729
|
|
|
765
730
|
private write(live: LiveTask, payload: Record<string, unknown>): void {
|
|
@@ -787,10 +752,8 @@ export class AgentRunner {
|
|
|
787
752
|
const live = this.live.get(id);
|
|
788
753
|
if (!live || live.terminal || !value || typeof value !== "object") return;
|
|
789
754
|
const raw = value as Record<string, unknown>;
|
|
790
|
-
const remediation = this.store.get(id)?.sddRemediation;
|
|
791
|
-
if (remediation) observeRemediationTool(remediation, raw);
|
|
792
|
-
this.armStall(id, live);
|
|
793
755
|
if (raw.type === "response") {
|
|
756
|
+
this.armStall(id, live);
|
|
794
757
|
if (!live.observationPreparation) this.checkObservationGrant(live);
|
|
795
758
|
const pending = typeof raw.id === "string" ? live.pending.get(raw.id) : undefined;
|
|
796
759
|
if (pending) {
|
|
@@ -800,7 +763,13 @@ export class AgentRunner {
|
|
|
800
763
|
return;
|
|
801
764
|
}
|
|
802
765
|
this.checkObservationGrant(live);
|
|
803
|
-
|
|
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) {
|
|
804
773
|
if (event.type === TASK_EVENT.RESPONSE_OBSERVATION) {
|
|
805
774
|
const buffer = live.observations;
|
|
806
775
|
if (buffer) {
|
|
@@ -809,19 +778,26 @@ export class AgentRunner {
|
|
|
809
778
|
}
|
|
810
779
|
continue; // Separate from store persistence, UI totals and notifications.
|
|
811
780
|
}
|
|
781
|
+
live.sawRunEvent = true;
|
|
812
782
|
this.store.apply(id, event, this.deps.now());
|
|
813
783
|
if (event.type === TASK_EVENT.TOOL_START && event.callId) {
|
|
784
|
+
live.inFlightTools.set(event.callId, event.name);
|
|
814
785
|
live.mutationStarts.delete(event.callId);
|
|
815
786
|
if ((event.name === "write" || event.name === "edit") && typeof event.args.path === "string" && event.args.path.trim()) {
|
|
816
787
|
live.mutationStarts.set(event.callId, { toolName: event.name, toolCallId: event.callId, path: event.args.path });
|
|
817
788
|
}
|
|
818
789
|
}
|
|
819
790
|
if (event.type === TASK_EVENT.TOOL_END) {
|
|
791
|
+
live.inFlightTools.delete(event.callId);
|
|
820
792
|
const mutation = live.mutationStarts.get(event.callId);
|
|
821
793
|
live.mutationStarts.delete(event.callId);
|
|
822
794
|
const task = this.store.get(id);
|
|
823
795
|
if (mutation && task && raw.isError === false && !event.isError) {
|
|
824
|
-
try {
|
|
796
|
+
try {
|
|
797
|
+
const evidence = (raw.result as { details?: { gentleSessionChange?: unknown } } | undefined)?.details?.gentleSessionChange;
|
|
798
|
+
const observed = isSessionChangeEvidence(evidence) && evidence.id === mutation.toolCallId ? { ...mutation, evidence: structuredClone(evidence) } : mutation;
|
|
799
|
+
void Promise.resolve(this.hooks.onSuccessfulMutation?.(task, observed)).catch(() => {});
|
|
800
|
+
}
|
|
825
801
|
catch { /* Bookkeeping failure must not rewrite a successful tool or stop the child. */ }
|
|
826
802
|
}
|
|
827
803
|
}
|
|
@@ -834,6 +810,7 @@ export class AgentRunner {
|
|
|
834
810
|
else this.requestStop(id, TASK_STATUS.FAILED, "assistant settled without a final report");
|
|
835
811
|
}
|
|
836
812
|
}
|
|
813
|
+
if (!live.terminal && progress) this.armStall(id, live);
|
|
837
814
|
}
|
|
838
815
|
|
|
839
816
|
// Task-mode subagents may ask the human through the host; background ones
|
|
@@ -876,6 +853,7 @@ export class AgentRunner {
|
|
|
876
853
|
if (!live || live.terminal) return;
|
|
877
854
|
live.terminal = { status, error };
|
|
878
855
|
live.mutationStarts.clear();
|
|
856
|
+
live.inFlightTools.clear();
|
|
879
857
|
live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
|
|
880
858
|
live.permissionBroker?.close();
|
|
881
859
|
this.closeIpc(live);
|
|
@@ -989,24 +967,7 @@ export class AgentRunner {
|
|
|
989
967
|
|
|
990
968
|
private finish(id: string, status: TaskRecord["status"], error: string | null, live?: LiveTask): void {
|
|
991
969
|
const current = this.store.get(id);
|
|
992
|
-
if (!current || isFinished(current.status)
|
|
993
|
-
const finalize = this.remediationFinalizers.get(id);
|
|
994
|
-
if (finalize) {
|
|
995
|
-
this.finalizingRemediation.add(id);
|
|
996
|
-
const terminal = { ...current, status, error };
|
|
997
|
-
void finalize(terminal, { spawned: typeof live?.child.pid === "number", exited: live?.childExit !== undefined, cleanupConfirmed: !live || !live.quarantined && live.childExit !== undefined }).then(() => {
|
|
998
|
-
status = terminal.status;
|
|
999
|
-
error = terminal.error;
|
|
1000
|
-
}).catch(() => {
|
|
1001
|
-
status = TASK_STATUS.FAILED;
|
|
1002
|
-
error = "Native remediation settlement could not be durably finalized; retain task history and reconcile before further execution";
|
|
1003
|
-
}).finally(() => {
|
|
1004
|
-
this.remediationFinalizers.delete(id);
|
|
1005
|
-
this.finalizingRemediation.delete(id);
|
|
1006
|
-
this.finish(id, status, error, live);
|
|
1007
|
-
});
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
970
|
+
if (!current || isFinished(current.status)) return;
|
|
1010
971
|
const finished = this.store.update(id, { status, endedAt: this.deps.now(), error, lastStep: error ?? "done" });
|
|
1011
972
|
if (finished) {
|
|
1012
973
|
if (live) this.checkObservationGrant(live);
|