gentle-pi 2.5.0 → 2.6.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 +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
package/lib/agents-runner.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { Duplex, Readable, Writable } from "node:stream";
|
|
2
|
-
import {
|
|
3
|
+
import { RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, type ResearchArtifactIntent } from "./sdd-research-capabilities.ts";
|
|
4
|
+
import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
|
|
5
|
+
import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
|
|
3
6
|
import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
|
|
4
|
-
import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type TaskRecord, type TaskStore } from "./agents-protocol.ts";
|
|
7
|
+
import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type RemediationTaskState, type TaskStore } from "./agents-protocol.ts";
|
|
5
8
|
|
|
6
9
|
// Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
|
|
7
10
|
// the host never runs subagent work on the TUI thread. It writes JSON
|
|
@@ -15,15 +18,19 @@ export interface ChildLike {
|
|
|
15
18
|
stderr: Readable | null | undefined;
|
|
16
19
|
stdio?: Array<Duplex | null | undefined>;
|
|
17
20
|
kill(signal?: NodeJS.Signals): boolean;
|
|
21
|
+
send?(message: Record<string, unknown>, callback?: (error: Error | null) => void): boolean;
|
|
22
|
+
disconnect?(): void;
|
|
23
|
+
channel?: { unref?(): void };
|
|
18
24
|
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
19
25
|
on(event: "error", listener: (error: Error) => void): unknown;
|
|
26
|
+
on(event: "spawn" | "message" | "disconnect", listener: (...args: unknown[]) => void): unknown;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
export interface SpawnOptions {
|
|
23
30
|
cwd: string;
|
|
24
31
|
env: NodeJS.ProcessEnv;
|
|
25
32
|
detached?: boolean;
|
|
26
|
-
stdio?: Array<"pipe" | "ignore" | "inherit">;
|
|
33
|
+
stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc">;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export type Spawn = (command: string, args: string[], options: SpawnOptions) => ChildLike;
|
|
@@ -57,12 +64,137 @@ export interface AskAnswer {
|
|
|
57
64
|
cancelled?: boolean;
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
export interface TaskQuery {
|
|
68
|
+
taskId: string;
|
|
69
|
+
requestId: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const MAX_CHILD_RESPONSE_OBSERVATIONS = 128;
|
|
73
|
+
|
|
74
|
+
/** Local producer snapshot only; never native workflow success or export authority.
|
|
75
|
+
* Scope excludes tools, compaction, hidden provider retries and history replay.
|
|
76
|
+
* Each message_end is retained separately; RPC supplies no stable dedupe key.
|
|
77
|
+
* Unsettled shutdown may lose in-flight responses even when droppedResponses is 0.
|
|
78
|
+
*/
|
|
79
|
+
export interface ChildObservationSnapshot {
|
|
80
|
+
readonly coverage: "final_assistant_messages_only";
|
|
81
|
+
readonly agentSettled: boolean;
|
|
82
|
+
readonly responses: readonly ChildResponseObservation[];
|
|
83
|
+
readonly droppedResponses: number;
|
|
84
|
+
}
|
|
85
|
+
interface ChildObservationBuffer {
|
|
86
|
+
agentSettled: boolean;
|
|
87
|
+
responses: ChildResponseObservation[];
|
|
88
|
+
droppedResponses: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
60
91
|
export interface RunnerHooks {
|
|
61
92
|
askUser(taskId: string, request: AskRequest, raw: Record<string, unknown>): Promise<AskAnswer>;
|
|
62
|
-
|
|
93
|
+
/** Optional immutable snapshot, delivered once at existing finalization.
|
|
94
|
+
* Undefined when collection was disabled or no child handle was created.
|
|
95
|
+
* Parent must check task.status AND agentSettled; observations are not success.
|
|
96
|
+
*/
|
|
97
|
+
onFinish?(task: TaskRecord, observations?: ChildObservationSnapshot): void;
|
|
98
|
+
// Accepts a child notification only while the originating parent session is active.
|
|
99
|
+
onNotification?(task: TaskRecord, message: string): boolean | void;
|
|
100
|
+
onQuery?(task: TaskRecord, requestId: string, message: string): boolean | void;
|
|
101
|
+
// Parent-only observation of a paired successful filesystem tool, not prose.
|
|
102
|
+
onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string }): void | Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RemediationHarnessPlan { command?: string; naReason?: string }
|
|
106
|
+
export interface RemediationRollbackPlan { boundary: string; command: string }
|
|
107
|
+
export interface RemediationScope { cwd: string; editPaths: string[]; commands: string[]; allowedEditRoots: string[] }
|
|
108
|
+
export interface RemediationPlan {
|
|
109
|
+
editPaths?: string[];
|
|
110
|
+
cwd: string;
|
|
111
|
+
commands: string[];
|
|
112
|
+
runtimeHarness: RemediationHarnessPlan;
|
|
113
|
+
rollback: RemediationRollbackPlan;
|
|
114
|
+
}
|
|
115
|
+
export interface RemediationObservation {
|
|
116
|
+
slot: number;
|
|
117
|
+
toolCallId: string;
|
|
118
|
+
command: string;
|
|
119
|
+
cwd: string;
|
|
120
|
+
exitCode: number | null;
|
|
121
|
+
result: string;
|
|
122
|
+
}
|
|
123
|
+
export interface RemediationObservations {
|
|
124
|
+
failedEvidenceRevision: string;
|
|
125
|
+
plan: RemediationPlan;
|
|
126
|
+
observations: RemediationObservation[];
|
|
127
|
+
pending: Record<string, number>;
|
|
128
|
+
invalid: boolean;
|
|
129
|
+
}
|
|
130
|
+
const concrete = (value: unknown): value is string => typeof value === "string" && value.trim() === value && value.length > 3 && value.length <= 4096 && !/[\0\r\n]/.test(value);
|
|
131
|
+
export function parseRemediationPlan(value: unknown, cwd: string): RemediationPlan {
|
|
132
|
+
const plan = value as RemediationPlan;
|
|
133
|
+
if (!plan || plan.cwd !== cwd || !Array.isArray(plan.commands) || plan.commands.length < 1 || plan.commands.length > 16 || !plan.commands.every(concrete) ||
|
|
134
|
+
!concrete(plan.rollback?.boundary) || !concrete(plan.rollback?.command) || !plan.runtimeHarness ||
|
|
135
|
+
!(concrete(plan.runtimeHarness.command) && plan.runtimeHarness.naReason === undefined || plan.runtimeHarness.command === undefined && concrete(plan.runtimeHarness.naReason) && plan.runtimeHarness.naReason.length >= 20 && /because/i.test(plan.runtimeHarness.naReason))) throw new TypeError("Invalid remediation evidence plan");
|
|
136
|
+
return structuredClone(plan);
|
|
137
|
+
}
|
|
138
|
+
export const plannedCommands = (plan: RemediationPlan) => [...plan.commands, ...(plan.runtimeHarness.command ? [plan.runtimeHarness.command] : []), plan.rollback.command];
|
|
139
|
+
const evidenceDigest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
140
|
+
|
|
141
|
+
// Only paired stock-shell observations may fill this remediation-only plan.
|
|
142
|
+
export function observeRemediationTool(state: RemediationObservations, raw: Record<string, unknown>): void {
|
|
143
|
+
if (raw.toolName !== "bash" || typeof raw.toolCallId !== "string") return;
|
|
144
|
+
const id = raw.toolCallId;
|
|
145
|
+
if (raw.type === "tool_execution_start") {
|
|
146
|
+
const command = (raw.args as { command?: unknown } | undefined)?.command;
|
|
147
|
+
if (typeof command !== "string" || !plannedCommands(state.plan).includes(command)) return;
|
|
148
|
+
if (Object.hasOwn(state.pending, id) || state.observations.some(item => item.toolCallId === id) || Object.keys(state.pending).length >= 32) { state.invalid = true; return; }
|
|
149
|
+
const slot = plannedCommands(state.plan).findIndex((item, index) => item === command && !state.observations.some(observation => observation.slot === index) && !Object.values(state.pending).includes(index));
|
|
150
|
+
if (slot < 0) { state.invalid = true; return; }
|
|
151
|
+
state.pending[id] = slot;
|
|
152
|
+
}
|
|
153
|
+
if (raw.type !== "tool_execution_end" || !Object.hasOwn(state.pending, id)) return;
|
|
154
|
+
const slot = state.pending[id];
|
|
155
|
+
const command = plannedCommands(state.plan)[slot];
|
|
156
|
+
delete state.pending[id];
|
|
157
|
+
const result = raw.result as { content?: unknown; details?: { truncation?: unknown; fullOutputPath?: unknown; remediationCommand?: RemediationObservation & { truncated?: boolean } } } | undefined;
|
|
158
|
+
const observed = result?.details?.remediationCommand;
|
|
159
|
+
const output = JSON.stringify(result?.content ?? null);
|
|
160
|
+
if (!observed || observed.toolCallId !== id || observed.command !== command || observed.cwd !== state.plan.cwd ||
|
|
161
|
+
!(observed.exitCode === null || Number.isInteger(observed.exitCode)) || state.observations.length >= 32) { state.invalid = true; return; }
|
|
162
|
+
const contentValid = Array.isArray(result?.content) && result.content.length > 0 && result.content.every(part =>
|
|
163
|
+
part && typeof part === "object" && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string");
|
|
164
|
+
if (raw.isError !== false || observed.exitCode !== 0 || observed.truncated || result?.details?.truncation || result?.details?.fullOutputPath || output.length > 16_000 || !contentValid) state.invalid = true;
|
|
165
|
+
state.observations.push({ slot, toolCallId: id, command, cwd: observed.cwd, exitCode: observed.exitCode, result: `Observed command output ${evidenceDigest(output)}: ${output.slice(0, 400)}` });
|
|
166
|
+
}
|
|
167
|
+
export function remediationEvidence(state: RemediationObservations) {
|
|
168
|
+
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);
|
|
169
|
+
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;
|
|
170
|
+
const result = (slot: number) => `cwd ${state.plan.cwd}; retained command observation ${evidenceDigest(JSON.stringify(find(slot)))}`;
|
|
171
|
+
return {
|
|
172
|
+
schema: "gentle-ai.remediation-evidence/v1",
|
|
173
|
+
failed_evidence_revision: state.failedEvidenceRevision,
|
|
174
|
+
commands: state.plan.commands.map((command, slot) => ({ command, exit_code: 0, result: result(slot) })),
|
|
175
|
+
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 },
|
|
176
|
+
rollback: { boundary: state.plan.rollback.boundary, evidence: result(plannedCommands(state.plan).length - 1) },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface SddChangeSelection {
|
|
181
|
+
changeName: string;
|
|
182
|
+
workspaceRoot: string;
|
|
183
|
+
phase: "apply" | "verify" | "sync" | "archive" | "remediate";
|
|
184
|
+
failedEvidenceRevision?: string;
|
|
63
185
|
}
|
|
64
186
|
|
|
187
|
+
export const SDD_CHANGE_FLAG = "--gentle-sdd-change";
|
|
188
|
+
|
|
189
|
+
export interface RemediationTerminalFacts { spawned: boolean; exited: boolean; cleanupConfirmed: boolean }
|
|
190
|
+
|
|
191
|
+
export const REMEDIATION_PLAN_ENV = "GENTLE_PI_SDD_REMEDIATION_PLAN";
|
|
192
|
+
|
|
65
193
|
export interface TaskRequest {
|
|
194
|
+
remediationIntent?: unknown;
|
|
195
|
+
sddRemediation?: RemediationTaskState;
|
|
196
|
+
sddPreflightContext?: string;
|
|
197
|
+
finalizeRemediation?: (task: TaskRecord, facts: RemediationTerminalFacts) => Promise<void>;
|
|
66
198
|
agent: AgentDefinition;
|
|
67
199
|
prompt: string;
|
|
68
200
|
label: string | undefined;
|
|
@@ -75,6 +207,29 @@ export interface TaskRequest {
|
|
|
75
207
|
sessionDir: string;
|
|
76
208
|
resumeSessionPath: string | undefined;
|
|
77
209
|
env: NodeJS.ProcessEnv;
|
|
210
|
+
// A launch-local SDD identity. It is never prompt text or shared state.
|
|
211
|
+
sddChange?: SddChangeSelection;
|
|
212
|
+
// Untrusted narrowing intent; paths come only from matching host provenance.
|
|
213
|
+
researchSelection?: unknown;
|
|
214
|
+
researchArtifact?: ResearchArtifactIntent;
|
|
215
|
+
extensionPaths?: string[];
|
|
216
|
+
// Captures the originating session; invoked only after successful OS spawn.
|
|
217
|
+
onLaunch?: () => void;
|
|
218
|
+
/** Default off. Parent owns policy before opting into bounded local buffering,
|
|
219
|
+
* and must recheck policy/catalog privacy before recording or forwarding.
|
|
220
|
+
* This flag does not authorize telemetry export or perform policy subprocesses.
|
|
221
|
+
*/
|
|
222
|
+
collectResponseObservations?: boolean;
|
|
223
|
+
/** Optional parallel preparation after dequeue; never delays OS spawn.
|
|
224
|
+
* Unready at the first observation checkpoint permanently drops collection. */
|
|
225
|
+
prepareResponseObservations?: () => Promise<boolean>;
|
|
226
|
+
/** Optional synchronous parent-local grant check; never perform I/O here.
|
|
227
|
+
* Parent checks environment, known revocation and a monotonic expiry against
|
|
228
|
+
* its fresh native policy grant. False/throw permanently discards this task's
|
|
229
|
+
* buffer. Checked once ready, on RPC values, and finish; this is not a watcher.
|
|
230
|
+
* Omission preserves the explicit opt-in producer API, not policy authority.
|
|
231
|
+
*/
|
|
232
|
+
canCollectResponseObservations?: () => boolean;
|
|
78
233
|
// This closure stays only in the parent process. Its presence creates an
|
|
79
234
|
// inherited fd, never an environment boolean or model-visible permission.
|
|
80
235
|
authorizeParentStandingReviewPermission?: (repositoryIdentity: string) => boolean;
|
|
@@ -90,9 +245,23 @@ interface Pending {
|
|
|
90
245
|
resolve(value: Record<string, unknown>): void;
|
|
91
246
|
}
|
|
92
247
|
|
|
248
|
+
interface PendingQuery {
|
|
249
|
+
cancel: () => void;
|
|
250
|
+
replying: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface PendingReply {
|
|
254
|
+
resolve(value: boolean): void;
|
|
255
|
+
}
|
|
256
|
+
|
|
93
257
|
interface LiveTask {
|
|
94
258
|
child: ChildLike;
|
|
259
|
+
observations?: ChildObservationBuffer;
|
|
260
|
+
observationGuard?: () => boolean;
|
|
261
|
+
observationPreparation?: () => boolean;
|
|
95
262
|
pending: Map<string, Pending>;
|
|
263
|
+
queries: Map<string, PendingQuery>;
|
|
264
|
+
replies: Map<string, PendingReply>;
|
|
96
265
|
cancelStall: () => void;
|
|
97
266
|
cancelGrace: () => void;
|
|
98
267
|
processGroup: number | undefined;
|
|
@@ -102,21 +271,56 @@ interface LiveTask {
|
|
|
102
271
|
quarantined: boolean;
|
|
103
272
|
nextId: number;
|
|
104
273
|
permissionBroker?: ParentStandingReviewPermissionBroker;
|
|
274
|
+
ipcClosed: boolean;
|
|
275
|
+
acknowledgedIpcIds: Set<string>;
|
|
276
|
+
acknowledgedIpcOrder: string[];
|
|
277
|
+
mutationStarts: Map<string, { toolName: "write" | "edit"; toolCallId: string; path: string }>;
|
|
105
278
|
}
|
|
106
279
|
|
|
107
280
|
const CHILD_MARKER = "GENTLE_PI_AGENTS_CHILD";
|
|
281
|
+
const IPC_MARKER = "GENTLE_PI_AGENTS_OWNED_IPC";
|
|
282
|
+
const PARENT_NOTIFICATION_TOOL = "subagent_parent_message";
|
|
108
283
|
const DEFAULT_TOOLS: readonly string[] = [];
|
|
109
284
|
const TERMINATION_GRACE_MS = 250;
|
|
110
285
|
const GROUP_CONFIRM_MS = 25;
|
|
111
286
|
const GROUP_CONFIRM_DEADLINE_MS = 1_000;
|
|
287
|
+
const QUERY_REJECTION_ERRORS = new Set([
|
|
288
|
+
"invalid child IPC frame",
|
|
289
|
+
"invalid child IPC correlation",
|
|
290
|
+
"unsupported child IPC kind",
|
|
291
|
+
"invalid child IPC message",
|
|
292
|
+
"task is not a live owned recipient",
|
|
293
|
+
"task parent cannot accept queries",
|
|
294
|
+
"task parent is not the active host session",
|
|
295
|
+
"duplicate query request",
|
|
296
|
+
"too many pending parent queries",
|
|
297
|
+
"parent query timed out",
|
|
298
|
+
"parent rejected query",
|
|
299
|
+
]);
|
|
300
|
+
const QUERY_REJECTION = Symbol("query rejection");
|
|
301
|
+
|
|
302
|
+
function rejectQuery(error: string): never {
|
|
303
|
+
throw { [QUERY_REJECTION]: error };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function queryRejection(error: unknown): string {
|
|
307
|
+
if (error && typeof error === "object" && QUERY_REJECTION in error) {
|
|
308
|
+
const value = (error as { [QUERY_REJECTION]?: unknown })[QUERY_REJECTION];
|
|
309
|
+
if (typeof value === "string" && QUERY_REJECTION_ERRORS.has(value)) return value;
|
|
310
|
+
}
|
|
311
|
+
return "parent rejected query";
|
|
312
|
+
}
|
|
313
|
+
|
|
112
314
|
const hostProcess: ProcessControl = { platform: process.platform, kill: (pid, signal) => process.kill(pid, signal) };
|
|
113
315
|
|
|
114
316
|
export function childArguments(request: TaskRequest): string[] {
|
|
115
317
|
const args = ["--mode", "rpc", "--session-dir", request.sessionDir];
|
|
318
|
+
for (const path of request.extensionPaths ?? []) args.push("--extension", path);
|
|
319
|
+
if (request.sddChange) args.push(SDD_CHANGE_FLAG, JSON.stringify(request.sddChange));
|
|
116
320
|
if (request.resumeSessionPath) args.push("--session", request.resumeSessionPath);
|
|
117
321
|
if (request.model) args.push("--model", request.thinking ? `${formatModelRef(request.model)}:${request.thinking}` : formatModelRef(request.model));
|
|
118
322
|
else if (request.thinking) args.push("--thinking", request.thinking);
|
|
119
|
-
const tools = request.agent.tools.length > 0 ? request.agent.tools : DEFAULT_TOOLS;
|
|
323
|
+
const tools = request.agent.tools.length > 0 ? [...new Set([...request.agent.tools, PARENT_NOTIFICATION_TOOL])] : DEFAULT_TOOLS;
|
|
120
324
|
if (tools.length > 0) args.push("--tools", tools.join(","));
|
|
121
325
|
if (request.agent.instructions.length > 0) args.push("--append-system-prompt", request.agent.instructions);
|
|
122
326
|
return args;
|
|
@@ -162,7 +366,8 @@ export class JsonLines {
|
|
|
162
366
|
}
|
|
163
367
|
|
|
164
368
|
export function promptText(request: TaskRequest): string {
|
|
165
|
-
|
|
369
|
+
const prompt = request.context ? `${request.prompt}\n\n## Context\n${request.context}` : request.prompt;
|
|
370
|
+
return request.sddRemediation ? `${prompt}\n\n## Host-owned remediation evidence plan\nExecute these exact commands in the selected cwd; do not perform native acquire or settle.\n${JSON.stringify(request.sddRemediation.plan)}\nFailed evidence: ${request.sddRemediation.failedEvidenceRevision}` : prompt;
|
|
166
371
|
}
|
|
167
372
|
|
|
168
373
|
export class AgentRunner {
|
|
@@ -173,7 +378,11 @@ export class AgentRunner {
|
|
|
173
378
|
private readonly processControl: ProcessControl;
|
|
174
379
|
private readonly queue: Array<{ task: TaskRecord; request: TaskRequest }> = [];
|
|
175
380
|
private readonly live = new Map<string, LiveTask>();
|
|
381
|
+
private readonly remediationFinalizers = new Map<string, NonNullable<TaskRequest["finalizeRemediation"]>>();
|
|
382
|
+
private readonly finalizingRemediation = new Set<string>();
|
|
176
383
|
private readonly waiters = new Map<string, Array<(task: TaskRecord) => void>>();
|
|
384
|
+
private readonly queryWaiters = new Map<string, Array<(query: TaskQuery | undefined) => void>>();
|
|
385
|
+
private readonly firstQueries = new Map<string, TaskQuery>();
|
|
177
386
|
private counter = 0;
|
|
178
387
|
|
|
179
388
|
constructor(store: TaskStore, limits: RunnerLimits, deps: RunnerDeps, hooks: RunnerHooks) {
|
|
@@ -184,14 +393,22 @@ export class AgentRunner {
|
|
|
184
393
|
this.processControl = deps.process ?? hostProcess;
|
|
185
394
|
}
|
|
186
395
|
|
|
187
|
-
|
|
396
|
+
prepareRemediation(request: TaskRequest): TaskRecord {
|
|
397
|
+
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");
|
|
398
|
+
return this.createTask(request);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private createTask(request: TaskRequest): TaskRecord {
|
|
188
402
|
const now = this.deps.now();
|
|
189
403
|
this.counter += 1;
|
|
190
404
|
const task: TaskRecord = {
|
|
191
405
|
id: `${now.toString(36)}-${this.counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
192
406
|
agent: request.agent.name,
|
|
407
|
+
...(request.sddRemediation ? { sddRemediation: structuredClone(request.sddRemediation) } : {}),
|
|
408
|
+
...(request.sddPreflightContext ? { sddPreflightContext: request.sddPreflightContext } : {}),
|
|
193
409
|
mode: request.mode,
|
|
194
410
|
prompt: request.prompt,
|
|
411
|
+
...(request.researchArtifact ? { researchArtifact: structuredClone(request.researchArtifact) } : {}),
|
|
195
412
|
label: taskLabel(request.prompt, request.label),
|
|
196
413
|
cwd: request.cwd,
|
|
197
414
|
parentSessionId: request.parentSessionId,
|
|
@@ -212,7 +429,22 @@ export class AgentRunner {
|
|
|
212
429
|
cost: 0,
|
|
213
430
|
};
|
|
214
431
|
this.store.add(task);
|
|
215
|
-
|
|
432
|
+
return task;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
run(request: TaskRequest, preparedRemediation?: TaskRecord): TaskRecord {
|
|
436
|
+
if (preparedRemediation && (this.store.get(preparedRemediation.id) !== preparedRemediation || preparedRemediation.cwd !== request.cwd || preparedRemediation.agent !== "sdd-remediate" || preparedRemediation.status !== TASK_STATUS.QUEUED || this.queue.some(entry => entry.task.id === preparedRemediation.id))) throw new Error("Invalid or already dispatched remediation task");
|
|
437
|
+
const task = preparedRemediation ?? this.createTask(request);
|
|
438
|
+
if (request.sddRemediation) task.sddRemediation = structuredClone(request.sddRemediation);
|
|
439
|
+
if (request.finalizeRemediation) this.remediationFinalizers.set(task.id, request.finalizeRemediation);
|
|
440
|
+
// A caller can retain and mutate its request after dispatch. Preserve only
|
|
441
|
+
// the identity selected at construction for this child launch.
|
|
442
|
+
const launchRequest = {
|
|
443
|
+
...request,
|
|
444
|
+
sddChange: request.sddChange && { ...request.sddChange },
|
|
445
|
+
researchArtifact: request.researchArtifact && structuredClone(request.researchArtifact),
|
|
446
|
+
};
|
|
447
|
+
this.queue.push({ task, request: launchRequest });
|
|
216
448
|
queueMicrotask(() => this.pump());
|
|
217
449
|
return task;
|
|
218
450
|
}
|
|
@@ -228,6 +460,33 @@ export class AgentRunner {
|
|
|
228
460
|
});
|
|
229
461
|
}
|
|
230
462
|
|
|
463
|
+
waitForQuery(id: string): Promise<TaskQuery | undefined> {
|
|
464
|
+
const current = this.store.get(id);
|
|
465
|
+
if (!current || isFinished(current.status)) return Promise.resolve(undefined);
|
|
466
|
+
const first = this.firstQueries.get(id);
|
|
467
|
+
if (first) return Promise.resolve(first);
|
|
468
|
+
return new Promise((resolve) => {
|
|
469
|
+
const list = this.queryWaiters.get(id) ?? [];
|
|
470
|
+
list.push(resolve);
|
|
471
|
+
this.queryWaiters.set(id, list);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async reply(id: string, requestId: string, message: string, parentSessionId: string): Promise<boolean> {
|
|
476
|
+
const task = this.store.get(id);
|
|
477
|
+
const live = this.live.get(id);
|
|
478
|
+
if (!task || !live || live.terminal || task.parentSessionId !== parentSessionId || !validChildMessage(message)) return false;
|
|
479
|
+
const query = live.queries.get(requestId);
|
|
480
|
+
if (!query || query.replying) return false;
|
|
481
|
+
query.replying = true;
|
|
482
|
+
const accepted = await this.sendReply(live, requestId, { id: requestId, kind: "reply", message });
|
|
483
|
+
if (live.queries.get(requestId) === query) {
|
|
484
|
+
query.cancel();
|
|
485
|
+
live.queries.delete(requestId);
|
|
486
|
+
}
|
|
487
|
+
return accepted;
|
|
488
|
+
}
|
|
489
|
+
|
|
231
490
|
cancel(id: string): boolean {
|
|
232
491
|
const queued = this.queue.findIndex((entry) => entry.task.id === id);
|
|
233
492
|
if (queued >= 0) {
|
|
@@ -240,6 +499,13 @@ export class AgentRunner {
|
|
|
240
499
|
return true;
|
|
241
500
|
}
|
|
242
501
|
|
|
502
|
+
/** Parent-known revocation clears buffered metadata immediately, including
|
|
503
|
+
* during an idle provider call. No task/store/status mutation. */
|
|
504
|
+
discardResponseObservations(id: string): void {
|
|
505
|
+
const live = this.live.get(id);
|
|
506
|
+
if (live) { live.observations = undefined; live.observationGuard = undefined; }
|
|
507
|
+
}
|
|
508
|
+
|
|
243
509
|
cancelAll(): number {
|
|
244
510
|
const ids = [...this.queue.map((entry) => entry.task.id), ...this.live.keys()];
|
|
245
511
|
return ids.filter((id) => this.cancel(id)).length;
|
|
@@ -255,7 +521,9 @@ export class AgentRunner {
|
|
|
255
521
|
private pump(): void {
|
|
256
522
|
while (this.live.size < this.limits.maxConcurrency && this.queue.length > 0) {
|
|
257
523
|
const entry = this.queue.shift();
|
|
258
|
-
if (entry)
|
|
524
|
+
if (!entry) continue;
|
|
525
|
+
const { task, request } = entry;
|
|
526
|
+
this.launch(task.id, request);
|
|
259
527
|
}
|
|
260
528
|
}
|
|
261
529
|
|
|
@@ -266,16 +534,20 @@ export class AgentRunner {
|
|
|
266
534
|
const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
|
|
267
535
|
const env = {
|
|
268
536
|
...request.env,
|
|
537
|
+
...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null), [RESEARCH_ARTIFACT_ENV]: JSON.stringify(request.researchArtifact ?? null) } : {}),
|
|
269
538
|
[CHILD_MARKER]: "1",
|
|
539
|
+
[IPC_MARKER]: `${this.deps.now()}-${Math.random().toString(36).slice(2)}`,
|
|
270
540
|
...(hasParentPermissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}),
|
|
271
541
|
};
|
|
542
|
+
delete env[REMEDIATION_PLAN_ENV];
|
|
543
|
+
if (request.sddRemediation) env[REMEDIATION_PLAN_ENV] = JSON.stringify({ plan: request.sddRemediation.plan, scope: request.sddRemediation.scope, selection: request.sddChange });
|
|
272
544
|
let child: ChildLike;
|
|
273
545
|
try {
|
|
274
546
|
child = this.deps.spawn(this.deps.pi.command, [...this.deps.pi.args, ...childArguments(request)], {
|
|
275
547
|
cwd: request.cwd,
|
|
276
548
|
env,
|
|
277
549
|
detached,
|
|
278
|
-
|
|
550
|
+
stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
|
|
279
551
|
});
|
|
280
552
|
} catch (error) {
|
|
281
553
|
this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
|
|
@@ -283,7 +555,18 @@ export class AgentRunner {
|
|
|
283
555
|
return;
|
|
284
556
|
}
|
|
285
557
|
const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
|
|
286
|
-
const live: LiveTask = { child, pending: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0 };
|
|
558
|
+
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: [] };
|
|
559
|
+
if (request.prepareResponseObservations) {
|
|
560
|
+
let ready = false;
|
|
561
|
+
live.observationPreparation = () => ready;
|
|
562
|
+
void Promise.resolve().then(() => this.live.get(id) === live && !live.terminal
|
|
563
|
+
? request.prepareResponseObservations!() : false).then(allowed => { ready = allowed === true; }).catch(() => {});
|
|
564
|
+
}
|
|
565
|
+
if (request.collectResponseObservations === true || request.prepareResponseObservations) {
|
|
566
|
+
live.observationGuard = request.canCollectResponseObservations;
|
|
567
|
+
live.observations = { agentSettled: false, responses: [], droppedResponses: 0 };
|
|
568
|
+
if (!request.prepareResponseObservations) this.checkObservationGrant(live);
|
|
569
|
+
}
|
|
287
570
|
this.live.set(id, live);
|
|
288
571
|
const permissionPipe = child.stdio?.[3];
|
|
289
572
|
if (hasParentPermissionChannel && permissionPipe !== undefined && permissionPipe !== null) {
|
|
@@ -293,7 +576,17 @@ export class AgentRunner {
|
|
|
293
576
|
);
|
|
294
577
|
}
|
|
295
578
|
this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
|
|
579
|
+
child.channel?.unref?.();
|
|
296
580
|
child.on("error", (error) => this.childError(id, error));
|
|
581
|
+
child.on("message", (value) => this.receiveChildMessage(id, value));
|
|
582
|
+
child.on("disconnect", () => this.closeIpc(live));
|
|
583
|
+
let announced = false;
|
|
584
|
+
child.on("spawn", () => {
|
|
585
|
+
if (announced || this.live.get(id) !== live || live.terminal) return;
|
|
586
|
+
announced = true;
|
|
587
|
+
try { request.onLaunch?.(); }
|
|
588
|
+
catch (error) { this.requestStop(id, TASK_STATUS.FAILED, `could not register launched worktree: ${error instanceof Error ? error.message : String(error)}`); }
|
|
589
|
+
});
|
|
297
590
|
child.stdin.on("error", () => {});
|
|
298
591
|
this.armStall(id, live);
|
|
299
592
|
const lines = new JsonLines((value) => this.receive(id, request, value));
|
|
@@ -301,9 +594,21 @@ export class AgentRunner {
|
|
|
301
594
|
child.stdout.on("data", (chunk: string) => lines.push(chunk));
|
|
302
595
|
child.stderr?.on("data", () => {});
|
|
303
596
|
child.on("exit", (code) => this.exited(id, code));
|
|
597
|
+
if (request.sddRemediation && child.pid === undefined) {
|
|
598
|
+
this.childError(id, new Error("remediation child has no process ID"));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
304
601
|
void this.send(id, { type: "get_state" }).then((response) => {
|
|
305
|
-
const data = response.data as { sessionFile?:
|
|
306
|
-
if (
|
|
602
|
+
const data = response.data as { sessionFile?: unknown; model?: { provider?: unknown; id?: unknown } | null; thinkingLevel?: unknown } | undefined;
|
|
603
|
+
if (response.success !== true || live.terminal || this.live.get(id) !== live || !data) return;
|
|
604
|
+
const resolved: Partial<TaskRecord> = {};
|
|
605
|
+
if (typeof data.sessionFile === "string" && data.sessionFile) resolved.sessionPath = data.sessionFile;
|
|
606
|
+
if (data.model === null) resolved.model = "default";
|
|
607
|
+
else if (typeof data.model?.provider === "string" && data.model.provider && typeof data.model.id === "string" && data.model.id) {
|
|
608
|
+
resolved.model = formatModelRef({ provider: data.model.provider, id: data.model.id });
|
|
609
|
+
}
|
|
610
|
+
if (typeof data.thinkingLevel === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(data.thinkingLevel)) resolved.thinking = data.thinkingLevel;
|
|
611
|
+
this.store.update(id, resolved);
|
|
307
612
|
});
|
|
308
613
|
void this.send(id, { type: "prompt", message: promptText(request) }).then((response) => {
|
|
309
614
|
if (response.success === false) this.requestStop(id, TASK_STATUS.FAILED, String(response.error ?? "prompt rejected"));
|
|
@@ -326,6 +631,104 @@ export class AgentRunner {
|
|
|
326
631
|
});
|
|
327
632
|
}
|
|
328
633
|
|
|
634
|
+
private receiveChildMessage(id: string, value: unknown): void {
|
|
635
|
+
const live = this.live.get(id);
|
|
636
|
+
if (!live || live.ipcClosed) return;
|
|
637
|
+
const parsed = parseChildFrame(value);
|
|
638
|
+
if (!parsed.frame) {
|
|
639
|
+
if (parsed.id && validChildQueryId(parsed.id)) this.sendQueryError(live, parsed.id, parsed.error ?? "invalid child IPC frame");
|
|
640
|
+
else if (parsed.id) this.acknowledge(live, parsed.id, false, parsed.error ?? "invalid child IPC frame");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const task = this.store.get(id);
|
|
644
|
+
if (!task || live.terminal || isFinished(task.status) || task.parentSessionId === "") {
|
|
645
|
+
if (parsed.frame.kind === "notification") this.acknowledge(live, parsed.frame.id, false, "task is not a live owned recipient");
|
|
646
|
+
else this.sendQueryError(live, parsed.frame.id, "task is not a live owned recipient");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (parsed.frame.kind === "notification") {
|
|
650
|
+
if (live.acknowledgedIpcIds.has(parsed.frame.id)) return;
|
|
651
|
+
try {
|
|
652
|
+
if (this.hooks.onNotification?.(task, parsed.frame.message) === false) this.acknowledge(live, parsed.frame.id, false, "task parent is not the active host session");
|
|
653
|
+
else this.acknowledge(live, parsed.frame.id, true);
|
|
654
|
+
} catch { this.acknowledge(live, parsed.frame.id, false, "parent rejected notification"); }
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
let query: PendingQuery | undefined;
|
|
658
|
+
try {
|
|
659
|
+
if (live.queries.has(parsed.frame.id)) rejectQuery("duplicate query request");
|
|
660
|
+
if (live.queries.size >= CHILD_QUERY_MAX_INFLIGHT) rejectQuery("too many pending parent queries");
|
|
661
|
+
query = { replying: false, cancel: this.deps.schedule(() => this.expireQuery(live, parsed.frame!.id), CHILD_QUERY_TIMEOUT_MS) };
|
|
662
|
+
live.queries.set(parsed.frame.id, query);
|
|
663
|
+
if (!this.hooks.onQuery) rejectQuery("task parent cannot accept queries");
|
|
664
|
+
if (this.hooks.onQuery(task, parsed.frame.id, parsed.frame.message) === false) rejectQuery("task parent is not the active host session");
|
|
665
|
+
if (task.mode === AGENT_MODE.TASK && !this.firstQueries.has(id)) {
|
|
666
|
+
const first = { taskId: id, requestId: parsed.frame.id };
|
|
667
|
+
this.firstQueries.set(id, first);
|
|
668
|
+
for (const resolve of this.queryWaiters.get(id) ?? []) resolve(first);
|
|
669
|
+
this.queryWaiters.delete(id);
|
|
670
|
+
}
|
|
671
|
+
} catch (error) {
|
|
672
|
+
if (query && live.queries.get(parsed.frame.id) === query) {
|
|
673
|
+
query.cancel();
|
|
674
|
+
live.queries.delete(parsed.frame.id);
|
|
675
|
+
}
|
|
676
|
+
this.sendQueryError(live, parsed.frame.id, queryRejection(error));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private expireQuery(live: LiveTask, id: string): void {
|
|
681
|
+
const query = live.queries.get(id);
|
|
682
|
+
if (!query) return;
|
|
683
|
+
live.queries.delete(id);
|
|
684
|
+
if (query.replying) this.settleReply(live, id, false);
|
|
685
|
+
else this.sendQueryError(live, id, "parent query timed out");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private sendQueryError(live: LiveTask, id: string, error: string): void {
|
|
689
|
+
const safeError = QUERY_REJECTION_ERRORS.has(error) ? error : "parent rejected query";
|
|
690
|
+
try { live.child.send?.({ id, kind: "reply", error: safeError }, () => {}); }
|
|
691
|
+
catch { /* Child-owned IPC callback reports transport failure. */ }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private acknowledge(live: LiveTask, id: string, accepted: boolean, error?: string): void {
|
|
695
|
+
if (live.ipcClosed || live.acknowledgedIpcIds.has(id) || !live.child.send) return;
|
|
696
|
+
live.acknowledgedIpcIds.add(id);
|
|
697
|
+
live.acknowledgedIpcOrder.push(id);
|
|
698
|
+
if (live.acknowledgedIpcOrder.length > 64) live.acknowledgedIpcIds.delete(live.acknowledgedIpcOrder.shift()!);
|
|
699
|
+
try { live.child.send({ id, kind: "ack", accepted, ...(error ? { error } : {}) }, () => {}); }
|
|
700
|
+
catch { /* Child-owned IPC callback reports transport failure. */ }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private sendReply(live: LiveTask, id: string, frame: Record<string, unknown>): Promise<boolean> {
|
|
704
|
+
return new Promise((resolve) => {
|
|
705
|
+
live.replies.set(id, { resolve });
|
|
706
|
+
try {
|
|
707
|
+
if (!live.child.send) this.settleReply(live, id, false);
|
|
708
|
+
else live.child.send(frame, (error) => this.settleReply(live, id, !error));
|
|
709
|
+
} catch { this.settleReply(live, id, false); }
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private settleReply(live: LiveTask, id: string, accepted: boolean): void {
|
|
714
|
+
const pending = live.replies.get(id);
|
|
715
|
+
if (!pending) return;
|
|
716
|
+
live.replies.delete(id);
|
|
717
|
+
pending.resolve(accepted);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private closeIpc(live: LiveTask): void {
|
|
721
|
+
if (live.ipcClosed) return;
|
|
722
|
+
live.ipcClosed = true;
|
|
723
|
+
for (const query of live.queries.values()) query.cancel();
|
|
724
|
+
live.queries.clear();
|
|
725
|
+
for (const pending of live.replies.values()) pending.resolve(false);
|
|
726
|
+
live.replies.clear();
|
|
727
|
+
live.child.channel?.unref?.();
|
|
728
|
+
try { live.child.disconnect?.(); }
|
|
729
|
+
catch { /* Channel may already be disconnected. */ }
|
|
730
|
+
}
|
|
731
|
+
|
|
329
732
|
private write(live: LiveTask, payload: Record<string, unknown>): void {
|
|
330
733
|
try {
|
|
331
734
|
live.child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
@@ -334,12 +737,28 @@ export class AgentRunner {
|
|
|
334
737
|
}
|
|
335
738
|
}
|
|
336
739
|
|
|
740
|
+
private checkObservationGrant(live: LiveTask): void {
|
|
741
|
+
if (live.observationPreparation) {
|
|
742
|
+
if (!live.observationPreparation()) live.observations = undefined;
|
|
743
|
+
live.observationPreparation = undefined; // One chance; late readiness cannot attach.
|
|
744
|
+
}
|
|
745
|
+
if (!live.observations || !live.observationGuard) return;
|
|
746
|
+
try {
|
|
747
|
+
if (live.observationGuard() === true) return;
|
|
748
|
+
} catch { /* Policy bookkeeping must not interrupt child execution. */ }
|
|
749
|
+
live.observations = undefined;
|
|
750
|
+
live.observationGuard = undefined;
|
|
751
|
+
}
|
|
752
|
+
|
|
337
753
|
private receive(id: string, request: TaskRequest, value: unknown): void {
|
|
338
754
|
const live = this.live.get(id);
|
|
339
755
|
if (!live || live.terminal || !value || typeof value !== "object") return;
|
|
340
756
|
const raw = value as Record<string, unknown>;
|
|
757
|
+
const remediation = this.store.get(id)?.sddRemediation;
|
|
758
|
+
if (remediation) observeRemediationTool(remediation, raw);
|
|
341
759
|
this.armStall(id, live);
|
|
342
760
|
if (raw.type === "response") {
|
|
761
|
+
if (!live.observationPreparation) this.checkObservationGrant(live);
|
|
343
762
|
const pending = typeof raw.id === "string" ? live.pending.get(raw.id) : undefined;
|
|
344
763
|
if (pending) {
|
|
345
764
|
live.pending.delete(raw.id as string);
|
|
@@ -347,10 +766,35 @@ export class AgentRunner {
|
|
|
347
766
|
}
|
|
348
767
|
return;
|
|
349
768
|
}
|
|
350
|
-
|
|
769
|
+
this.checkObservationGrant(live);
|
|
770
|
+
for (const event of normalizeRpcEvent(raw, { observeResponses: live.observations !== undefined })) {
|
|
771
|
+
if (event.type === TASK_EVENT.RESPONSE_OBSERVATION) {
|
|
772
|
+
const buffer = live.observations;
|
|
773
|
+
if (buffer) {
|
|
774
|
+
if (buffer.responses.length < MAX_CHILD_RESPONSE_OBSERVATIONS) buffer.responses.push(event.observation);
|
|
775
|
+
else buffer.droppedResponses = Math.min(Number.MAX_SAFE_INTEGER, buffer.droppedResponses + 1);
|
|
776
|
+
}
|
|
777
|
+
continue; // Separate from store persistence, UI totals and notifications.
|
|
778
|
+
}
|
|
351
779
|
this.store.apply(id, event, this.deps.now());
|
|
780
|
+
if (event.type === TASK_EVENT.TOOL_START && event.callId) {
|
|
781
|
+
live.mutationStarts.delete(event.callId);
|
|
782
|
+
if ((event.name === "write" || event.name === "edit") && typeof event.args.path === "string" && event.args.path.trim()) {
|
|
783
|
+
live.mutationStarts.set(event.callId, { toolName: event.name, toolCallId: event.callId, path: event.args.path });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (event.type === TASK_EVENT.TOOL_END) {
|
|
787
|
+
const mutation = live.mutationStarts.get(event.callId);
|
|
788
|
+
live.mutationStarts.delete(event.callId);
|
|
789
|
+
const task = this.store.get(id);
|
|
790
|
+
if (mutation && task && raw.isError === false && !event.isError) {
|
|
791
|
+
try { void Promise.resolve(this.hooks.onSuccessfulMutation?.(task, mutation)).catch(() => {}); }
|
|
792
|
+
catch { /* Bookkeeping failure must not rewrite a successful tool or stop the child. */ }
|
|
793
|
+
}
|
|
794
|
+
}
|
|
352
795
|
if (event.type === TASK_EVENT.ASK) void this.answer(id, request, live, event.request, raw);
|
|
353
796
|
if (event.type === TASK_EVENT.AGENT_SETTLED) {
|
|
797
|
+
if (live.observations) live.observations.agentSettled = true;
|
|
354
798
|
const terminal = this.store.get(id);
|
|
355
799
|
if (terminal?.error) this.requestStop(id, TASK_STATUS.FAILED, terminal.error);
|
|
356
800
|
else if (terminal?.result) this.requestStop(id, TASK_STATUS.COMPLETED, null);
|
|
@@ -398,8 +842,10 @@ export class AgentRunner {
|
|
|
398
842
|
const live = this.live.get(id);
|
|
399
843
|
if (!live || live.terminal) return;
|
|
400
844
|
live.terminal = { status, error };
|
|
845
|
+
live.mutationStarts.clear();
|
|
401
846
|
live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
|
|
402
847
|
live.permissionBroker?.close();
|
|
848
|
+
this.closeIpc(live);
|
|
403
849
|
live.cancelStall();
|
|
404
850
|
if (abort) void this.send(id, { type: "abort" });
|
|
405
851
|
this.signal(live, "SIGTERM");
|
|
@@ -410,29 +856,59 @@ export class AgentRunner {
|
|
|
410
856
|
}, TERMINATION_GRACE_MS);
|
|
411
857
|
}
|
|
412
858
|
|
|
413
|
-
|
|
414
|
-
|
|
859
|
+
// A false result is ambiguous, so the probe reports which one it is: an ESRCH
|
|
860
|
+
// result proves the group is gone, while an absent process group means the
|
|
861
|
+
// question cannot be asked at all. Only the first justifies treating the exit as
|
|
862
|
+
// complete without an observed exit event.
|
|
863
|
+
private probeGroup(live: LiveTask): "present" | "gone" | "unavailable" {
|
|
864
|
+
if (live.processGroup === undefined) return "unavailable";
|
|
415
865
|
try {
|
|
416
866
|
this.processControl.kill(-live.processGroup, 0);
|
|
417
|
-
return
|
|
867
|
+
return "present";
|
|
418
868
|
} catch (error) {
|
|
419
|
-
return (error as NodeJS.ErrnoException).code
|
|
869
|
+
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "gone" : "present";
|
|
420
870
|
}
|
|
421
871
|
}
|
|
422
872
|
|
|
873
|
+
private groupExists(live: LiveTask): boolean {
|
|
874
|
+
return this.probeGroup(live) === "present";
|
|
875
|
+
}
|
|
876
|
+
|
|
423
877
|
private confirmGroupExit(id: string, live: LiveTask): void {
|
|
424
878
|
if (this.live.get(id) !== live) return;
|
|
425
|
-
|
|
879
|
+
const group = this.probeGroup(live);
|
|
880
|
+
if (group === "present") {
|
|
426
881
|
if (this.deps.now() >= (live.cleanupDeadlineAt ?? 0)) {
|
|
427
882
|
live.cancelGrace();
|
|
428
883
|
live.quarantined = true;
|
|
429
|
-
this.finish(id, TASK_STATUS.FAILED, `process cleanup unconfirmed after ${GROUP_CONFIRM_DEADLINE_MS}ms; capacity quarantined
|
|
884
|
+
this.finish(id, TASK_STATUS.FAILED, `process cleanup unconfirmed after ${GROUP_CONFIRM_DEADLINE_MS}ms; capacity quarantined`, live);
|
|
430
885
|
return;
|
|
431
886
|
}
|
|
432
887
|
live.cancelGrace = this.deps.schedule(() => this.confirmGroupExit(id, live), GROUP_CONFIRM_MS);
|
|
433
888
|
return;
|
|
434
889
|
}
|
|
435
|
-
if (
|
|
890
|
+
if (group === "gone") {
|
|
891
|
+
// No process remains in the group, so the exit is complete whether or not
|
|
892
|
+
// the child's own exit event was ever observed. Completing here also frees
|
|
893
|
+
// the concurrency slot; finishing without it would leave the task recorded
|
|
894
|
+
// while its slot stayed occupied and queued work never pumped.
|
|
895
|
+
this.completeExit(id, live);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (live.childExit !== undefined) {
|
|
899
|
+
this.completeExit(id, live);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
// With no process group to probe, an observed exit is the only confirmation
|
|
903
|
+
// available. Wait for it within the deadline, then quarantine instead of
|
|
904
|
+
// completing on an assumption, and never return without either.
|
|
905
|
+
if (this.deps.now() >= (live.cleanupDeadlineAt ?? 0)) {
|
|
906
|
+
live.cancelGrace();
|
|
907
|
+
live.quarantined = true;
|
|
908
|
+
this.finish(id, TASK_STATUS.FAILED, `child exit unconfirmed after ${GROUP_CONFIRM_DEADLINE_MS}ms; capacity quarantined`, live);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
live.cancelGrace = this.deps.schedule(() => this.confirmGroupExit(id, live), GROUP_CONFIRM_MS);
|
|
436
912
|
}
|
|
437
913
|
|
|
438
914
|
private childError(id: string, error: Error): void {
|
|
@@ -445,10 +921,11 @@ export class AgentRunner {
|
|
|
445
921
|
return;
|
|
446
922
|
}
|
|
447
923
|
live.permissionBroker?.close();
|
|
924
|
+
this.closeIpc(live);
|
|
448
925
|
live.cancelStall();
|
|
449
926
|
live.cancelGrace();
|
|
450
927
|
this.live.delete(id);
|
|
451
|
-
this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error.message}
|
|
928
|
+
this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error.message}`, live);
|
|
452
929
|
}
|
|
453
930
|
|
|
454
931
|
private exited(id: string, code: number | null): void {
|
|
@@ -464,6 +941,7 @@ export class AgentRunner {
|
|
|
464
941
|
|
|
465
942
|
private completeExit(id: string, live: LiveTask): void {
|
|
466
943
|
live.permissionBroker?.close();
|
|
944
|
+
this.closeIpc(live);
|
|
467
945
|
live.cancelStall();
|
|
468
946
|
live.cancelGrace();
|
|
469
947
|
this.live.delete(id);
|
|
@@ -473,18 +951,62 @@ export class AgentRunner {
|
|
|
473
951
|
return;
|
|
474
952
|
}
|
|
475
953
|
const terminal = live.terminal;
|
|
476
|
-
this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled
|
|
954
|
+
this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled`, live);
|
|
477
955
|
}
|
|
478
956
|
|
|
479
|
-
private finish(id: string, status: TaskRecord["status"], error: string | null): void {
|
|
957
|
+
private finish(id: string, status: TaskRecord["status"], error: string | null, live?: LiveTask): void {
|
|
480
958
|
const current = this.store.get(id);
|
|
481
|
-
if (!current || isFinished(current.status)) return;
|
|
959
|
+
if (!current || isFinished(current.status) || this.finalizingRemediation.has(id)) return;
|
|
960
|
+
const finalize = this.remediationFinalizers.get(id);
|
|
961
|
+
if (finalize) {
|
|
962
|
+
this.finalizingRemediation.add(id);
|
|
963
|
+
const terminal = { ...current, status, error };
|
|
964
|
+
void finalize(terminal, { spawned: typeof live?.child.pid === "number", exited: live?.childExit !== undefined, cleanupConfirmed: !live || !live.quarantined && live.childExit !== undefined }).then(() => {
|
|
965
|
+
status = terminal.status;
|
|
966
|
+
error = terminal.error;
|
|
967
|
+
}).catch(() => {
|
|
968
|
+
status = TASK_STATUS.FAILED;
|
|
969
|
+
error = "Native remediation settlement could not be durably finalized; retain task history and reconcile before further execution";
|
|
970
|
+
}).finally(() => {
|
|
971
|
+
this.remediationFinalizers.delete(id);
|
|
972
|
+
this.finalizingRemediation.delete(id);
|
|
973
|
+
this.finish(id, status, error, live);
|
|
974
|
+
});
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
482
977
|
const finished = this.store.update(id, { status, endedAt: this.deps.now(), error, lastStep: error ?? "done" });
|
|
483
978
|
if (finished) {
|
|
484
|
-
this.
|
|
979
|
+
if (live) this.checkObservationGrant(live);
|
|
980
|
+
const buffer = live?.observations;
|
|
981
|
+
const snapshot: ChildObservationSnapshot | undefined = buffer ? Object.freeze({
|
|
982
|
+
coverage: "final_assistant_messages_only", agentSettled: buffer.agentSettled,
|
|
983
|
+
responses: Object.freeze(buffer.responses.slice()), droppedResponses: buffer.droppedResponses,
|
|
984
|
+
}) : undefined;
|
|
985
|
+
if (live) {
|
|
986
|
+
live.observations = undefined; // Also release quarantined buffers.
|
|
987
|
+
live.observationGuard = undefined;
|
|
988
|
+
}
|
|
989
|
+
this.hooks.onFinish?.(finished, snapshot);
|
|
485
990
|
for (const resolve of this.waiters.get(id) ?? []) resolve(finished);
|
|
486
991
|
this.waiters.delete(id);
|
|
992
|
+
for (const resolve of this.queryWaiters.get(id) ?? []) resolve(undefined);
|
|
993
|
+
this.queryWaiters.delete(id);
|
|
994
|
+
this.firstQueries.delete(id);
|
|
487
995
|
}
|
|
488
996
|
queueMicrotask(() => this.pump());
|
|
489
997
|
}
|
|
490
998
|
}
|
|
999
|
+
|
|
1000
|
+
// Human-readable suffix for an abort signal's reason, so a cancelled tool call is
|
|
1001
|
+
// distinguishable in the record and the notification rather than reported only as
|
|
1002
|
+
// "aborted". Returns an empty string when there is no usable reason.
|
|
1003
|
+
export function abortReasonText(reason: unknown): string {
|
|
1004
|
+
if (reason === undefined) return "";
|
|
1005
|
+
const message =
|
|
1006
|
+
reason instanceof Error && reason.message.length > 0
|
|
1007
|
+
? reason.message
|
|
1008
|
+
: typeof reason === "string" && reason.length > 0
|
|
1009
|
+
? reason
|
|
1010
|
+
: "";
|
|
1011
|
+
return message.length > 0 ? ` (${message})` : "";
|
|
1012
|
+
}
|