gentle-pi 2.6.1 → 2.6.3
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 +183 -943
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +9 -8
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +3 -3
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +7 -2
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/lens.schema.json +2 -2
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -1
- package/contracts/review-provider-contract-mirror/v1.2.0/generated/provider-capabilities.baseline.json +1 -1
- package/contracts/review-provider-contract-mirror/v1.2.0/generated/provider-roles.baseline.json +2 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/docs/assets/brand/gentle-pi-banner.svg +33 -0
- package/docs/assets/brand/terminal-divider.svg +17 -0
- package/docs/assets/diagrams/agent-orchestration.svg +19 -0
- package/docs/assets/diagrams/gentleman-workflow.svg +15 -0
- package/docs/assets/diagrams/native-review.svg +16 -0
- package/docs/assets/diagrams/sdd-cycle.svg +14 -0
- package/docs/assets/features/gentle-shell.png +0 -0
- package/docs/gentle-shell.md +151 -0
- package/docs/readme-reference.md +868 -0
- package/extensions/gentle-agents.ts +122 -9
- package/extensions/gentle-ai.ts +65 -22
- package/lib/agents-history.ts +89 -2
- package/lib/agents-runner.ts +44 -11
- package/lib/native-review-cli.ts +22 -0
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +22 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-history.test.ts +23 -1
- package/tests/agents-runner.test.ts +154 -2
- package/tests/gentle-agents.test.ts +102 -4
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +3 -2
- package/tests/native-review-capability-contract.test.ts +29 -1
- package/tests/package-manifest.test.ts +19 -18
- package/tests/review-authority-recovery-docs.test.ts +13 -13
- package/tests/review-controller-native-routing.test.ts +49 -0
- package/tests/review-ledger-contract.test.ts +7 -5
- package/tests/sdd-managed-runtime-settlement.test.ts +112 -0
- package/tests/sdd-selection-transport.test.ts +57 -0
- package/tests/skill-collision-prefixes.test.ts +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { extractParentConfirmedSddPreflightContext, getPackageAssetOwner, isParentConfirmedSddPreflightContext, SHIPPED_SDD_AGENT_NAMES } from "../lib/sdd-preflight.ts";
|
|
3
|
-
import { NativeReviewCliV216, NativeReviewCliError, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli, type NativeSddAcquireRequest, type NativeSddSettleRequest } from "../lib/native-review-cli.ts";
|
|
3
|
+
import { NativeReviewCliV216, NativeReviewCliError, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli, type NativeSddAcquireRequest, type NativeSddAttemptResult, type NativeSddSettleRequest } from "../lib/native-review-cli.ts";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
|
|
6
6
|
import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
@@ -19,7 +19,7 @@ import { isFinished, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord }
|
|
|
19
19
|
import { AgentRunner, piCommand, abortReasonText, plannedCommands, type RemediationPlan, type RemediationScope, REMEDIATION_PLAN_ENV, parseRemediationPlan, remediationEvidence, type AskAnswer, type RunnerDeps, type SddChangeSelection, type TaskRequest, type RemediationTerminalFacts } from "../lib/agents-runner.ts";
|
|
20
20
|
import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
|
|
21
21
|
import { hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentitySync, type ReviewSessionManager } from "../lib/review-session-standing-permission.ts";
|
|
22
|
-
import { historyDir, remediationUnresolved, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
|
|
22
|
+
import { acquireTaskLock, historyDir, remediationUnresolved, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
|
|
23
23
|
import { sessionToMarkdown } from "../lib/agents-transcript.ts";
|
|
24
24
|
import { AgentsView } from "../lib/agents-view.ts";
|
|
25
25
|
import { PresencePublisher } from "../lib/orchestrator-presence.ts";
|
|
@@ -129,6 +129,97 @@ export function remediationToolAllowed(scope: RemediationScope | undefined, cwd:
|
|
|
129
129
|
} catch { return false; }
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
export interface RemediationReconciliationResult {
|
|
133
|
+
acquireState?: NativeSddAttemptResult["state"];
|
|
134
|
+
settlementState?: NativeSddAttemptResult["state"];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function replayUncertainNativeMutation<T>(label: "acquire" | "settlement", invoke: () => Promise<T>): Promise<T> {
|
|
138
|
+
try { return await invoke(); }
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error instanceof TypeError || error instanceof NativeReviewCliError && error.mutationOutcome === "none") throw error;
|
|
141
|
+
try { return await invoke(); }
|
|
142
|
+
catch { throw new Error(`Native remediation ${label} remains unresolved; retry only this exact retained task`); }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function finishReconciledTask(task: TaskRecord, message: string): void {
|
|
147
|
+
task.status = TASK_STATUS.FAILED;
|
|
148
|
+
task.error = message;
|
|
149
|
+
task.lastStep = "reconciled";
|
|
150
|
+
task.endedAt ??= Date.now();
|
|
151
|
+
task.lastActivityAt = Date.now();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function reconcileManagedRemediation(task: TaskRecord, native: NativeReviewCli, persist: (task: TaskRecord) => Promise<void>): Promise<RemediationReconciliationResult> {
|
|
155
|
+
const state = task.sddRemediation;
|
|
156
|
+
if (task.agent !== "sdd-remediate" || !state?.acquire || state.acquire.workspaceRoot !== task.cwd) throw new Error("Task is not an exact managed remediation record");
|
|
157
|
+
if (state.settlement) throw new Error("Managed remediation task already has a terminal settlement");
|
|
158
|
+
|
|
159
|
+
const settleExact = async (settle: NativeSddSettleRequest, acquireState?: NativeSddAttemptResult["state"]): Promise<RemediationReconciliationResult> => {
|
|
160
|
+
if (!native.sddAttemptSettle) throw new Error("Native remediation settlement reconciliation is unavailable");
|
|
161
|
+
let settlement: NativeSddAttemptResult;
|
|
162
|
+
try { settlement = await replayUncertainNativeMutation("settlement", () => native.sddAttemptSettle!(structuredClone(settle))); }
|
|
163
|
+
catch (error) {
|
|
164
|
+
state.settlementUncertain = true;
|
|
165
|
+
await persist(task);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
state.settlement = structuredClone(settlement);
|
|
169
|
+
delete state.settlementUncertain;
|
|
170
|
+
finishReconciledTask(task, `Managed remediation settlement reconciled as ${settlement.state}${settlement.reason ? `(${settlement.reason})` : ""}; no actor started`);
|
|
171
|
+
await persist(task);
|
|
172
|
+
return { ...(acquireState === undefined ? {} : { acquireState }), settlementState: settlement.state };
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if (state.settle) {
|
|
176
|
+
const settle = state.settle, acquire = state.acquire;
|
|
177
|
+
const sameUntracked = JSON.stringify({ scope: settle.untrackedScope, inventory: settle.expectedUntrackedInventory, intended: settle.intendedUntracked }) === JSON.stringify({ scope: acquire.untrackedScope, inventory: acquire.expectedUntrackedInventory, intended: acquire.intendedUntracked });
|
|
178
|
+
if (state.acquireResult?.state !== "proceed" || !state.token || state.acquireResult.token !== state.token || settle.token !== state.token || settle.workspaceRoot !== acquire.workspaceRoot || settle.changeName !== acquire.changeName || settle.remediatesEvidenceRevision !== acquire.remediatesEvidenceRevision || !sameUntracked) throw new Error("Retained remediation settlement does not match its exact acquired authority");
|
|
179
|
+
return settleExact(structuredClone(settle), state.acquireResult.state);
|
|
180
|
+
}
|
|
181
|
+
if (!state.acquireUncertain) throw new Error("Managed remediation task has no uncertain acquire or settlement to reconcile");
|
|
182
|
+
if (state.actorClaimed) throw new Error("Managed remediation actor effects are uncertain; exact settlement or maintainer intervention is required");
|
|
183
|
+
if (state.acquireResult || state.token || state.settlementUncertain) throw new Error("Managed remediation acquire history is inconsistent; maintainer intervention is required");
|
|
184
|
+
if (!native.sddAttemptAcquire) throw new Error("Native remediation acquire reconciliation is unavailable");
|
|
185
|
+
|
|
186
|
+
let acquired: NativeSddAttemptResult;
|
|
187
|
+
try { acquired = await replayUncertainNativeMutation("acquire", () => native.sddAttemptAcquire!(structuredClone(state.acquire))); }
|
|
188
|
+
catch (error) {
|
|
189
|
+
state.acquireUncertain = true;
|
|
190
|
+
await persist(task);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
if (acquired.state === "proceed" && !acquired.token) throw new Error("Native remediation acquire reconciliation returned no token");
|
|
194
|
+
state.acquireResult = structuredClone(acquired);
|
|
195
|
+
delete state.acquireUncertain;
|
|
196
|
+
if (acquired.state !== "proceed") {
|
|
197
|
+
delete state.token;
|
|
198
|
+
finishReconciledTask(task, `Managed remediation acquire reconciled as ${acquired.state}${acquired.reason ? `(${acquired.reason})` : ""}; no actor started`);
|
|
199
|
+
await persist(task);
|
|
200
|
+
return { acquireState: acquired.state };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
state.token = acquired.token;
|
|
204
|
+
const acquire = state.acquire;
|
|
205
|
+
const settle: NativeSddSettleRequest = {
|
|
206
|
+
workspaceRoot: acquire.workspaceRoot,
|
|
207
|
+
changeName: acquire.changeName,
|
|
208
|
+
token: acquired.token!,
|
|
209
|
+
requestId: `reconcile-${createHash("sha256").update(JSON.stringify(acquire)).digest("hex").slice(0, 32)}`,
|
|
210
|
+
outcome: "interrupted",
|
|
211
|
+
diagnosis: "Acquire outcome was recovered after the actor launch boundary was refused; no managed actor was launched",
|
|
212
|
+
harnessDisposition: "invalidated",
|
|
213
|
+
cleanupEvidence: "No managed actor was spawned; no child cleanup was required",
|
|
214
|
+
processEvidence: "spawned=false; actor_claimed=false; reconciliation=acquire",
|
|
215
|
+
remediatesEvidenceRevision: acquire.remediatesEvidenceRevision,
|
|
216
|
+
...(acquire.untrackedScope === undefined ? {} : { untrackedScope: acquire.untrackedScope, expectedUntrackedInventory: acquire.expectedUntrackedInventory, intendedUntracked: acquire.intendedUntracked }),
|
|
217
|
+
};
|
|
218
|
+
state.settle = structuredClone(settle);
|
|
219
|
+
await persist(task); // Recovered token and exact settlement replay input become durable atomically before native mutation.
|
|
220
|
+
return settleExact(settle, acquired.state);
|
|
221
|
+
}
|
|
222
|
+
|
|
132
223
|
export async function admitManagedRemediation(request: TaskRequest, input: unknown, native: NativeReviewCli, persist: (task: TaskRecord) => Promise<void>, context?: Pick<ExtensionContext, "hasUI" | "ui">, preparedTask?: TaskRecord): Promise<Partial<TaskRequest>> {
|
|
133
224
|
const selected = request.sddChange;
|
|
134
225
|
const asset = fileURLToPath(new URL("../assets/agents/sdd-remediate.md", import.meta.url));
|
|
@@ -197,9 +288,12 @@ export async function admitManagedRemediation(request: TaskRequest, input: unkno
|
|
|
197
288
|
task.error = "Managed remediation lacks complete passing planned-command evidence";
|
|
198
289
|
}
|
|
199
290
|
if (payload.outcome === "passed" && state.settlement && state.settlement.state !== "blocked") task.status = TASK_STATUS.COMPLETED;
|
|
200
|
-
if (!state.settlement
|
|
291
|
+
if (!state.settlement) {
|
|
201
292
|
task.status = TASK_STATUS.FAILED;
|
|
202
293
|
task.error = "Native remediation settlement unresolved; retain exact history for reconciliation";
|
|
294
|
+
} else if (state.settlement.state === "blocked") {
|
|
295
|
+
task.status = TASK_STATUS.FAILED;
|
|
296
|
+
task.error = `Native remediation settlement blocked(${state.settlement.reason ?? "unspecified"}); current native admission decides any later attempt`;
|
|
203
297
|
}
|
|
204
298
|
await persist(task);
|
|
205
299
|
},
|
|
@@ -868,7 +962,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
868
962
|
const selected = store.get(task.id);
|
|
869
963
|
if (!isOwnedActive(selected)) return;
|
|
870
964
|
if (selected.status === TASK_STATUS.QUEUED) {
|
|
871
|
-
if (runner.cancel(selected.id)) ctx.ui.notify(`Stopped ${selected.agent}.`);
|
|
965
|
+
if (runner.cancel(selected.id, "stopped from the agents panel")) ctx.ui.notify(`Stopped ${selected.agent}.`);
|
|
872
966
|
else ctx.ui.notify(`Task ${selected.agent} already finished.`, "warning");
|
|
873
967
|
return;
|
|
874
968
|
}
|
|
@@ -882,7 +976,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
882
976
|
ctx.ui.notify(`Task ${selected.agent} already finished.`, "warning");
|
|
883
977
|
return;
|
|
884
978
|
}
|
|
885
|
-
if (runner.cancel(current.id)) ctx.ui.notify(`Stopped ${current.agent}.`);
|
|
979
|
+
if (runner.cancel(current.id, "stopped from the agents panel")) ctx.ui.notify(`Stopped ${current.agent}.`);
|
|
886
980
|
else ctx.ui.notify(`Task ${current.agent} already finished.`, "warning");
|
|
887
981
|
} finally {
|
|
888
982
|
stoppingTaskIds.delete(selected.id);
|
|
@@ -901,7 +995,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
901
995
|
const confirmation = (async () => {
|
|
902
996
|
try {
|
|
903
997
|
if (!await ctx.ui.confirm(`Stop ${count} active ${noun}?`, `Only these ${count} ${noun} will stop. Current work may be incomplete.`)) return;
|
|
904
|
-
const cancelled = active.filter((task) => runner.cancel(task.id)).length;
|
|
998
|
+
const cancelled = active.filter((task) => runner.cancel(task.id, "stopped from the agents panel (stop all)")).length;
|
|
905
999
|
ctx.ui.notify(`Stopped ${cancelled} ${cancelled === 1 ? "subagent" : "subagents"}.`);
|
|
906
1000
|
} finally {
|
|
907
1001
|
stopAllConfirmation = undefined;
|
|
@@ -1133,7 +1227,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1133
1227
|
// no recorded reason. Cancel through the runner so the lifecycle runs and the
|
|
1134
1228
|
// record is persisted, and tell the user why.
|
|
1135
1229
|
const onAbort = (): void => {
|
|
1136
|
-
if (runner.cancel(task.id)) {
|
|
1230
|
+
if (runner.cancel(task.id, `cancelled: the tool call was aborted${abortReasonText(signal?.reason)}`)) {
|
|
1137
1231
|
ctx.ui.notify(
|
|
1138
1232
|
`Subagent ${task.agent} cancelled: the tool call was aborted${abortReasonText(signal?.reason)}. The run is recorded as cancelled.`,
|
|
1139
1233
|
"warning",
|
|
@@ -1217,6 +1311,25 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1217
1311
|
return task ? text(describeTask(task), taskDetails(task)) : text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
|
|
1218
1312
|
});
|
|
1219
1313
|
|
|
1314
|
+
tool("reconcile", "Reconcile one retained managed remediation mutation without launching an actor.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => {
|
|
1315
|
+
const id = String(params.task_id);
|
|
1316
|
+
const lock = acquireTaskLock(tasksDir, id);
|
|
1317
|
+
try {
|
|
1318
|
+
const stored = await loadStoredTask(tasksDir, id);
|
|
1319
|
+
if (!stored) return text(`Error: no task ${id}`, { error: "unknown task" });
|
|
1320
|
+
const task = stored.task, retainedThread = stored.thread;
|
|
1321
|
+
const target = registryFor(ctx).validate(task.cwd);
|
|
1322
|
+
if (target !== resolve(task.cwd) || task.sddRemediation?.acquire.workspaceRoot !== target) throw new Error("Retained remediation task must resolve to its exact worktree in the same Git clone as this session");
|
|
1323
|
+
const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
|
|
1324
|
+
const persist = async (current: TaskRecord) => {
|
|
1325
|
+
await saveTask(tasksDir, current, retainedThread);
|
|
1326
|
+
store.update(current.id, { status: current.status, error: current.error, lastStep: current.lastStep, endedAt: current.endedAt, lastActivityAt: current.lastActivityAt, sddRemediation: current.sddRemediation });
|
|
1327
|
+
};
|
|
1328
|
+
const result = await reconcileManagedRemediation(task, native, persist);
|
|
1329
|
+
return text(`Managed remediation task ${task.id} reconciled; no actor started. Use fresh native status and admission for later work.`, { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode }, reconciliation: { ...result, actorStarted: false } });
|
|
1330
|
+
} finally { lock.release(); }
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1220
1333
|
tool("result", "Return the final answer of a finished subagent task, or its current state if it is still running.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
|
|
1221
1334
|
const task = await resolveTask(String(params.task_id));
|
|
1222
1335
|
if (!task) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
|
|
@@ -1238,7 +1351,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1238
1351
|
|
|
1239
1352
|
tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
|
|
1240
1353
|
const id = String(params.task_id);
|
|
1241
|
-
return runner.cancel(id) ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
|
|
1354
|
+
return runner.cancel(id, "cancelled by the cancel tool") ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
|
|
1242
1355
|
});
|
|
1243
1356
|
|
|
1244
1357
|
tool("send_message", "Steer a running subagent with a message delivered before its next model call.", { required: ["task_id", "message"], properties: { task_id: { type: "string" }, message: { type: "string" } } }, async (params) => {
|
|
@@ -1328,6 +1441,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1328
1441
|
sidebarTui = undefined;
|
|
1329
1442
|
worktrees?.close();
|
|
1330
1443
|
worktrees = undefined;
|
|
1331
|
-
runner.cancelAll();
|
|
1444
|
+
runner.cancelAll("cancelled: parent session shut down");
|
|
1332
1445
|
});
|
|
1333
1446
|
}
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -1839,7 +1839,15 @@ async function resolveSelectedNativeSddChangeStartup(
|
|
|
1839
1839
|
}
|
|
1840
1840
|
if (selection.phase === "remediate") {
|
|
1841
1841
|
if (status.nextRecommended !== "remediate" || status.remediationState?.failedEvidenceRevision !== selection.failedEvidenceRevision) throw new Error("Stale remediation selection");
|
|
1842
|
-
} else if (status.nextRecommended !== selection.phase || status.dependencies[selection.phase] !== "ready" || status.blockedReasons.length > 0) {
|
|
1842
|
+
} else if (status.nextRecommended !== selection.phase || status.dependencies[selection.phase] !== "ready" || (status.blockedReasons.length > 0 && selection.phase !== "verify")) {
|
|
1843
|
+
// Native's contract gates terminal, archive, and apply work on a
|
|
1844
|
+
// non-empty `blockedReasons`, and it deliberately keeps the `verify`
|
|
1845
|
+
// route runnable, because the blocker can name the evidence refresh
|
|
1846
|
+
// that is its own remedy ("failed verification evidence is incomplete;
|
|
1847
|
+
// rerun SDD verification", gentle-ai#3538). Vetoing that route made the
|
|
1848
|
+
// native-recommended phase unreachable (gentle-pi#972). The other
|
|
1849
|
+
// phases still fail closed, and every blocker stays in the injected
|
|
1850
|
+
// status for reporting.
|
|
1843
1851
|
throw new Error(`SDD selection native status blocks phase ${selection.phase}; it cannot execute.`);
|
|
1844
1852
|
}
|
|
1845
1853
|
return { selection, status };
|
|
@@ -5601,6 +5609,33 @@ function completeNativeStart(
|
|
|
5601
5609
|
};
|
|
5602
5610
|
}
|
|
5603
5611
|
|
|
5612
|
+
// gentle-ai#4003: every Pi-side teardown step that fails after the native
|
|
5613
|
+
// burn is deferred cleanup, not a failed acknowledgement. Only the
|
|
5614
|
+
// already-sanitized CandidateViewError surface is relayed; anything else is
|
|
5615
|
+
// reduced to the step's fixed code so no path or command text reaches the
|
|
5616
|
+
// caller. The candidate-view hint is out-of-band on purpose: no controller
|
|
5617
|
+
// operation exposes a cleanup-only retry, and replaying acknowledge-approved
|
|
5618
|
+
// would hit the already-burned lineage.
|
|
5619
|
+
const POST_BURN_CLEANUP = {
|
|
5620
|
+
candidateView: { code: "candidate-view-cleanup-failed", nextAction: "retry-candidate-view-cleanup-or-remove-the-view-out-of-band" },
|
|
5621
|
+
retainedSelection: { code: "retained-selection-cleanup-failed", nextAction: "retained-selection-clears-on-the-next-terminal-status" },
|
|
5622
|
+
} as const;
|
|
5623
|
+
|
|
5624
|
+
function deferredPostBurnCleanup(step: (typeof POST_BURN_CLEANUP)[keyof typeof POST_BURN_CLEANUP], cleanup: () => void): Record<string, unknown> | undefined {
|
|
5625
|
+
try {
|
|
5626
|
+
cleanup();
|
|
5627
|
+
return undefined;
|
|
5628
|
+
} catch (error) {
|
|
5629
|
+
return {
|
|
5630
|
+
status: "deferred",
|
|
5631
|
+
diagnostics: error instanceof CandidateViewError
|
|
5632
|
+
? { code: error.reason, message: error.message }
|
|
5633
|
+
: { code: step.code },
|
|
5634
|
+
next_action: step.nextAction,
|
|
5635
|
+
};
|
|
5636
|
+
}
|
|
5637
|
+
}
|
|
5638
|
+
|
|
5604
5639
|
function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_review_capture", error: unknown): Record<string, unknown> {
|
|
5605
5640
|
const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown; code?: unknown; continuation?: { command?: unknown } } };
|
|
5606
5641
|
if (isRecord(value.failureEnvelope) && isRecord(value.failureEnvelope.raw)) {
|
|
@@ -7335,42 +7370,50 @@ async function executeReviewControllerOperation(
|
|
|
7335
7370
|
} catch (error) {
|
|
7336
7371
|
return nativeOperationFailure(parameters.operation, error);
|
|
7337
7372
|
}
|
|
7373
|
+
let acknowledged: NativeReviewAcknowledgeApprovedOutcome | void;
|
|
7338
7374
|
try {
|
|
7339
7375
|
// gentle-ai #3947: the burn answers with one review-acknowledged/v1
|
|
7340
7376
|
// envelope bound to exactly this lineage, target, and revision, and
|
|
7341
7377
|
// the burn is reported from that envelope, never from a later
|
|
7342
7378
|
// STATUS. Every published release up to v2.5.0-rc.3 still burns in
|
|
7343
7379
|
// silence, and that result stays byte-identical.
|
|
7344
|
-
|
|
7380
|
+
acknowledged = await acknowledgementCli.acknowledgeApproved({
|
|
7345
7381
|
argumentTokens,
|
|
7346
7382
|
cwd: defaultCwd,
|
|
7347
7383
|
binding: { lineageId: parameters.lineageId, targetIdentity: status.targetIdentity, revision: status.authority.revision },
|
|
7348
7384
|
...(signal === undefined ? {} : { signal }),
|
|
7349
7385
|
});
|
|
7350
|
-
clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId);
|
|
7351
|
-
// The registry owns restoring writability of its 0555 views before
|
|
7352
|
-
// removal; a terminal approved cleanup keeps the lineage projection.
|
|
7353
|
-
candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd);
|
|
7354
|
-
// gentle-pi#668: `closed` is never auto-derived or recorded here --
|
|
7355
|
-
// a parent that wants the on-path passes nativeReviewOutcome:
|
|
7356
|
-
// "closed" explicitly on its next assess call for this candidate.
|
|
7357
|
-
return {
|
|
7358
|
-
operation: parameters.operation,
|
|
7359
|
-
status: "closed",
|
|
7360
|
-
outcome: "native-approved-acknowledgement-completed",
|
|
7361
|
-
lineage_id: parameters.lineageId,
|
|
7362
|
-
target_identity: status.targetIdentity,
|
|
7363
|
-
...(acknowledged === undefined ? {} : { consumed_revision: acknowledged.consumedRevision }),
|
|
7364
|
-
authority: "burned",
|
|
7365
|
-
...(acknowledged === undefined ? {} : { burn_evidence: acknowledged.schema }),
|
|
7366
|
-
delivery: "ordinary-repository-policy",
|
|
7367
|
-
mutation_performed: true,
|
|
7368
|
-
mutation_outcome: "committed",
|
|
7369
|
-
};
|
|
7370
7386
|
} catch (error) {
|
|
7371
7387
|
if (!nativeMutationRequiresStatus(error)) return nativeOperationFailure(parameters.operation, error);
|
|
7372
7388
|
return await reconcileNativeMutationFailure(parameters.operation, error, acknowledgementCli, target, retainedUntrackedSelections);
|
|
7373
7389
|
}
|
|
7390
|
+
// gentle-ai#4003: from here the native burn is the committed authority
|
|
7391
|
+
// outcome. Both Pi-side teardown steps run outside the mutation-result
|
|
7392
|
+
// try/catch and each one is guarded on its own, so a cleanup failure is
|
|
7393
|
+
// reported as deferred cleanup and never as a failed acknowledgement
|
|
7394
|
+
// that would invite a replay of a burned operation.
|
|
7395
|
+
const retainedSelectionCleanup = deferredPostBurnCleanup(POST_BURN_CLEANUP.retainedSelection, () => clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId));
|
|
7396
|
+
// The registry owns restoring writability of its 0555 views before
|
|
7397
|
+
// removal; a terminal approved cleanup keeps the lineage projection.
|
|
7398
|
+
const candidateViewCleanup = deferredPostBurnCleanup(POST_BURN_CLEANUP.candidateView, () => candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd));
|
|
7399
|
+
// gentle-pi#668: `closed` is never auto-derived or recorded here --
|
|
7400
|
+
// a parent that wants the on-path passes nativeReviewOutcome:
|
|
7401
|
+
// "closed" explicitly on its next assess call for this candidate.
|
|
7402
|
+
return {
|
|
7403
|
+
operation: parameters.operation,
|
|
7404
|
+
status: "closed",
|
|
7405
|
+
outcome: "native-approved-acknowledgement-completed",
|
|
7406
|
+
lineage_id: parameters.lineageId,
|
|
7407
|
+
target_identity: status.targetIdentity,
|
|
7408
|
+
...(acknowledged === undefined ? {} : { consumed_revision: acknowledged.consumedRevision }),
|
|
7409
|
+
authority: "burned",
|
|
7410
|
+
...(acknowledged === undefined ? {} : { burn_evidence: acknowledged.schema }),
|
|
7411
|
+
delivery: "ordinary-repository-policy",
|
|
7412
|
+
mutation_performed: true,
|
|
7413
|
+
mutation_outcome: "committed",
|
|
7414
|
+
...(retainedSelectionCleanup === undefined ? {} : { retained_selection_cleanup: retainedSelectionCleanup }),
|
|
7415
|
+
...(candidateViewCleanup === undefined ? {} : { candidate_view_cleanup: candidateViewCleanup }),
|
|
7416
|
+
};
|
|
7374
7417
|
}
|
|
7375
7418
|
if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT) {
|
|
7376
7419
|
const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
|
package/lib/agents-history.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1
4
|
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
3
7
|
import { emptyThread, type TaskRecord, type TaskThread } from "./agents-protocol.ts";
|
|
4
8
|
|
|
5
9
|
// Gentle Agents history: one JSON file per finished task, written by the
|
|
@@ -16,7 +20,13 @@ export function remediationUnresolved(task: TaskRecord): boolean {
|
|
|
16
20
|
const state = task.sddRemediation;
|
|
17
21
|
if (!state) return false;
|
|
18
22
|
if (state.acquireUncertain || state.settlementUncertain) return true;
|
|
19
|
-
|
|
23
|
+
// A received settlement is a definite native outcome, whatever its state
|
|
24
|
+
// (including "blocked"): it is terminal task history, never local
|
|
25
|
+
// ambiguity. Native admission is the sole authority over any later
|
|
26
|
+
// attempt for the same cwd/change; only genuinely uncertain outcomes, or
|
|
27
|
+
// no settlement at all with a still-retained token/claimed actor, are
|
|
28
|
+
// unresolved.
|
|
29
|
+
if (state.settlement) return false;
|
|
20
30
|
return !!state.token || !!state.actorClaimed || !["blocked", "complete"].includes(state.acquireResult?.state ?? "");
|
|
21
31
|
}
|
|
22
32
|
|
|
@@ -32,6 +42,83 @@ function fileFor(dir: string, id: string): string {
|
|
|
32
42
|
return join(dir, `${id}${FILE_SUFFIX}`);
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
const TASK_LOCK_SCHEMA = "gentle-pi.task-reconciliation-lock/v1" as const;
|
|
46
|
+
const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
47
|
+
type TaskLockOwner = { schema: typeof TASK_LOCK_SCHEMA; taskId: string; token: string; pid: number; host: string | null };
|
|
48
|
+
export interface TaskLock { readonly path: string; readonly taskId: string; readonly token: string; release(): void; }
|
|
49
|
+
function taskLockHost(): string | null {
|
|
50
|
+
try {
|
|
51
|
+
const hostname = os.hostname().trim();
|
|
52
|
+
if (process.platform === "linux") { const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(), namespace = readlinkSync("/proc/self/ns/pid"); return UUID.test(boot) && /^pid:\[\d+\]$/.test(namespace) ? `linux:${hostname}:${boot}:${namespace}` : null; }
|
|
53
|
+
if (process.platform === "darwin") { const boot = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8", timeout: 1000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] }).trim(); return UUID.test(boot) ? `darwin:${hostname}:${boot.toLowerCase()}` : null; }
|
|
54
|
+
return hostname ? `${process.platform}:${hostname}` : null;
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
function errorCode(error: unknown): string | undefined {
|
|
58
|
+
return typeof error === "object" && error !== null && "code" in error && typeof (error as { code?: unknown }).code === "string" ? (error as { code: string }).code : undefined;
|
|
59
|
+
}
|
|
60
|
+
function lockBusy(path: string, reason: string): Error { return new Error(`Task reconciliation lock is busy or ambiguous at ${path}: ${reason}`); }
|
|
61
|
+
function validTaskLockOwner(value: unknown, taskId: string): value is TaskLockOwner {
|
|
62
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
63
|
+
const owner = value as Partial<TaskLockOwner>;
|
|
64
|
+
return Object.keys(value).sort().join(",") === "host,pid,schema,taskId,token" && owner.schema === TASK_LOCK_SCHEMA && owner.taskId === taskId && typeof owner.token === "string" && UUID.test(owner.token) && Number.isSafeInteger(owner.pid) && owner.pid > 0 && (owner.host === null || typeof owner.host === "string" && owner.host.length > 0 && !owner.host.includes("\0"));
|
|
65
|
+
}
|
|
66
|
+
function ownerAt(path: string, taskId: string, token: string): TaskLockOwner {
|
|
67
|
+
if (!path.endsWith(`${taskId}.reconcile.${token}`)) throw lockBusy(path, "candidate filename is malformed");
|
|
68
|
+
const stat = lstatSync(path);
|
|
69
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw lockBusy(path, "candidate is not an expected regular file");
|
|
70
|
+
try {
|
|
71
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
72
|
+
if (!validTaskLockOwner(parsed, taskId) || parsed.token !== token) throw new Error("filename and owner metadata disagree");
|
|
73
|
+
return parsed;
|
|
74
|
+
} catch (error) { throw lockBusy(path, `candidate owner metadata is malformed${error instanceof Error ? `: ${error.message}` : ""}`); }
|
|
75
|
+
}
|
|
76
|
+
function ownerDead(owner: TaskLockOwner): boolean {
|
|
77
|
+
const host = taskLockHost();
|
|
78
|
+
if (!owner.host || !host || owner.host !== host || owner.pid === process.pid) return false;
|
|
79
|
+
try { process.kill(owner.pid, 0); return false; } catch (error) { return errorCode(error) === "ESRCH"; }
|
|
80
|
+
}
|
|
81
|
+
function syncTaskLock(path: string, directory = false): void {
|
|
82
|
+
if (directory && process.platform === "win32") return;
|
|
83
|
+
const descriptor = openSync(path, directory ? "r" : "r+");
|
|
84
|
+
try { fsyncSync(descriptor); } finally { closeSync(descriptor); }
|
|
85
|
+
}
|
|
86
|
+
function publishExclusive(path: string, owner: TaskLockOwner): void {
|
|
87
|
+
const temporary = `${path}.tmp`;
|
|
88
|
+
try { writeFileSync(temporary, JSON.stringify(owner), { encoding: "utf8", flag: "wx", mode: 0o600 }); syncTaskLock(temporary); linkSync(temporary, path); syncTaskLock(dirname(path), true); }
|
|
89
|
+
finally { try { unlinkSync(temporary); } catch {} }
|
|
90
|
+
}
|
|
91
|
+
function scanTaskCandidates(dir: string, id: string, ownPath: string): void {
|
|
92
|
+
const prefix = `${id}.reconcile.`;
|
|
93
|
+
for (const name of readdirSync(dir)) {
|
|
94
|
+
if (!name.startsWith(prefix) || name.endsWith(".tmp")) continue;
|
|
95
|
+
const path = join(dir, name), token = name.slice(prefix.length);
|
|
96
|
+
if (!UUID.test(token)) throw lockBusy(path, "candidate filename is malformed");
|
|
97
|
+
let owner: TaskLockOwner;
|
|
98
|
+
try { owner = ownerAt(path, id, token); } catch (error) { if (errorCode(error) === "ENOENT") throw lockBusy(path, "candidate disappeared during election"); throw error; }
|
|
99
|
+
if (path === ownPath) continue;
|
|
100
|
+
if (ownerDead(owner)) { try { unlinkSync(path); } catch (error) { if (errorCode(error) !== "ENOENT") throw error; } continue; }
|
|
101
|
+
throw lockBusy(path, "owner is live, foreign, or its death is inconclusive");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function releaseTaskLock(path: string, owner: TaskLockOwner): void {
|
|
105
|
+
let current: TaskLockOwner;
|
|
106
|
+
try { current = ownerAt(path, owner.taskId, owner.token); } catch (error) { if (errorCode(error) === "ENOENT") return; throw error; }
|
|
107
|
+
if (current.pid !== owner.pid || current.host !== owner.host) throw new Error("Task reconciliation lock owner token does not match");
|
|
108
|
+
unlinkSync(path); syncTaskLock(dirname(path), true);
|
|
109
|
+
}
|
|
110
|
+
export function acquireTaskLock(dir: string, id: string): TaskLock {
|
|
111
|
+
if (!SAFE_ID.test(id)) throw new Error(`invalid task id: ${id}`);
|
|
112
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
113
|
+
const owner: TaskLockOwner = { schema: TASK_LOCK_SCHEMA, taskId: id, token: randomUUID(), pid: process.pid, host: taskLockHost() };
|
|
114
|
+
const path = join(dir, `${id}.reconcile.${owner.token}`);
|
|
115
|
+
publishExclusive(path, owner);
|
|
116
|
+
try { scanTaskCandidates(dir, id, path); }
|
|
117
|
+
catch (error) { try { releaseTaskLock(path, owner); } catch {} throw error; }
|
|
118
|
+
let released = false;
|
|
119
|
+
return { path, taskId: id, token: owner.token, release() { if (!released) { releaseTaskLock(path, owner); released = true; } } };
|
|
120
|
+
}
|
|
121
|
+
|
|
35
122
|
function isRecord(value: unknown): value is TaskRecord {
|
|
36
123
|
const task = value as Partial<TaskRecord> | undefined;
|
|
37
124
|
return typeof task?.id === "string" && typeof task.agent === "string" && typeof task.status === "string" && typeof task.createdAt === "number";
|
package/lib/agents-runner.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { Duplex, Readable, Writable } from "node:stream";
|
|
3
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
4
|
import { RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, type ResearchArtifactIntent } from "./sdd-research-capabilities.ts";
|
|
4
5
|
import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
|
|
5
6
|
import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
|
|
@@ -275,8 +276,13 @@ interface LiveTask {
|
|
|
275
276
|
acknowledgedIpcIds: Set<string>;
|
|
276
277
|
acknowledgedIpcOrder: string[];
|
|
277
278
|
mutationStarts: Map<string, { toolName: "write" | "edit"; toolCallId: string; path: string }>;
|
|
279
|
+
// Bounded ring buffer of the child's raw stderr output, capped to the last
|
|
280
|
+
// STDERR_TAIL_MAX characters. Only surfaced on the stall and pre-settle exit
|
|
281
|
+
// terminal paths, never on completed, cancelled, or other failure reasons.
|
|
282
|
+
stderrTail: string;
|
|
278
283
|
}
|
|
279
284
|
|
|
285
|
+
const STDERR_TAIL_MAX = 512;
|
|
280
286
|
const CHILD_MARKER = "GENTLE_PI_AGENTS_CHILD";
|
|
281
287
|
const IPC_MARKER = "GENTLE_PI_AGENTS_OWNED_IPC";
|
|
282
288
|
const PARENT_NOTIFICATION_TOOL = "subagent_parent_message";
|
|
@@ -487,15 +493,15 @@ export class AgentRunner {
|
|
|
487
493
|
return accepted;
|
|
488
494
|
}
|
|
489
495
|
|
|
490
|
-
cancel(id: string): boolean {
|
|
496
|
+
cancel(id: string, reason = "cancelled"): boolean {
|
|
491
497
|
const queued = this.queue.findIndex((entry) => entry.task.id === id);
|
|
492
498
|
if (queued >= 0) {
|
|
493
499
|
this.queue.splice(queued, 1);
|
|
494
|
-
this.finish(id, TASK_STATUS.CANCELLED,
|
|
500
|
+
this.finish(id, TASK_STATUS.CANCELLED, `${reason} before start`);
|
|
495
501
|
return true;
|
|
496
502
|
}
|
|
497
503
|
if (!this.live.has(id)) return false;
|
|
498
|
-
this.requestStop(id, TASK_STATUS.CANCELLED,
|
|
504
|
+
this.requestStop(id, TASK_STATUS.CANCELLED, reason, true);
|
|
499
505
|
return true;
|
|
500
506
|
}
|
|
501
507
|
|
|
@@ -506,9 +512,9 @@ export class AgentRunner {
|
|
|
506
512
|
if (live) { live.observations = undefined; live.observationGuard = undefined; }
|
|
507
513
|
}
|
|
508
514
|
|
|
509
|
-
cancelAll(): number {
|
|
515
|
+
cancelAll(reason = "cancelled"): number {
|
|
510
516
|
const ids = [...this.queue.map((entry) => entry.task.id), ...this.live.keys()];
|
|
511
|
-
return ids.filter((id) => this.cancel(id)).length;
|
|
517
|
+
return ids.filter((id) => this.cancel(id, reason)).length;
|
|
512
518
|
}
|
|
513
519
|
|
|
514
520
|
steer(id: string, message: string): boolean {
|
|
@@ -555,7 +561,7 @@ export class AgentRunner {
|
|
|
555
561
|
return;
|
|
556
562
|
}
|
|
557
563
|
const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
|
|
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: [] };
|
|
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: "" };
|
|
559
565
|
if (request.prepareResponseObservations) {
|
|
560
566
|
let ready = false;
|
|
561
567
|
live.observationPreparation = () => ready;
|
|
@@ -592,7 +598,11 @@ export class AgentRunner {
|
|
|
592
598
|
const lines = new JsonLines((value) => this.receive(id, request, value));
|
|
593
599
|
child.stdout.setEncoding("utf8");
|
|
594
600
|
child.stdout.on("data", (chunk: string) => lines.push(chunk));
|
|
595
|
-
child.stderr?.
|
|
601
|
+
child.stderr?.setEncoding("utf8");
|
|
602
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
603
|
+
const tail = live.stderrTail + chunk;
|
|
604
|
+
live.stderrTail = tail.length > STDERR_TAIL_MAX ? tail.slice(-STDERR_TAIL_MAX) : tail;
|
|
605
|
+
});
|
|
596
606
|
child.on("exit", (code) => this.exited(id, code));
|
|
597
607
|
if (request.sddRemediation && child.pid === undefined) {
|
|
598
608
|
this.childError(id, new Error("remediation child has no process ID"));
|
|
@@ -602,6 +612,7 @@ export class AgentRunner {
|
|
|
602
612
|
const data = response.data as { sessionFile?: unknown; model?: { provider?: unknown; id?: unknown } | null; thinkingLevel?: unknown } | undefined;
|
|
603
613
|
if (response.success !== true || live.terminal || this.live.get(id) !== live || !data) return;
|
|
604
614
|
const resolved: Partial<TaskRecord> = {};
|
|
615
|
+
if (this.canAdvanceLastStep(id, ["starting"])) resolved.lastStep = "pi ready";
|
|
605
616
|
if (typeof data.sessionFile === "string" && data.sessionFile) resolved.sessionPath = data.sessionFile;
|
|
606
617
|
if (data.model === null) resolved.model = "default";
|
|
607
618
|
else if (typeof data.model?.provider === "string" && data.model.provider && typeof data.model.id === "string" && data.model.id) {
|
|
@@ -611,13 +622,35 @@ export class AgentRunner {
|
|
|
611
622
|
this.store.update(id, resolved);
|
|
612
623
|
});
|
|
613
624
|
void this.send(id, { type: "prompt", message: promptText(request) }).then((response) => {
|
|
614
|
-
if (response.success === false)
|
|
625
|
+
if (response.success === false) {
|
|
626
|
+
this.requestStop(id, TASK_STATUS.FAILED, String(response.error ?? "prompt rejected"));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (!live.terminal && this.live.get(id) === live && this.canAdvanceLastStep(id, ["starting", "pi ready"])) this.store.update(id, { lastStep: "prompt accepted" });
|
|
615
630
|
});
|
|
616
631
|
}
|
|
617
632
|
|
|
633
|
+
// A late get_state/prompt reply must never overwrite a stage that a child
|
|
634
|
+
// event (or the other reply) has already advanced lastStep past.
|
|
635
|
+
private canAdvanceLastStep(id: string, from: readonly string[]): boolean {
|
|
636
|
+
const current = this.store.get(id)?.lastStep;
|
|
637
|
+
return current !== undefined && from.includes(current);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Cleaned for display only: raw bytes stay in live.stderrTail so later
|
|
641
|
+
// appends keep working from the unstripped ring buffer.
|
|
642
|
+
private stderrSuffix(live: LiveTask): string {
|
|
643
|
+
const cleaned = stripVTControlCharacters(live.stderrTail).replace(/\s+/g, " ").trim();
|
|
644
|
+
return cleaned ? `; stderr: ${cleaned}` : "";
|
|
645
|
+
}
|
|
646
|
+
|
|
618
647
|
private armStall(id: string, live: LiveTask): void {
|
|
619
648
|
live.cancelStall();
|
|
620
|
-
live.cancelStall = this.deps.schedule(() =>
|
|
649
|
+
live.cancelStall = this.deps.schedule(() => {
|
|
650
|
+
const lastStep = this.store.get(id)?.lastStep ?? "starting";
|
|
651
|
+
const minutes = Math.round(this.limits.stallTimeoutMs / 60_000);
|
|
652
|
+
this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${minutes} min after: ${lastStep}${this.stderrSuffix(live)}`);
|
|
653
|
+
}, this.limits.stallTimeoutMs);
|
|
621
654
|
}
|
|
622
655
|
|
|
623
656
|
private send(id: string, command: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
@@ -933,7 +966,7 @@ export class AgentRunner {
|
|
|
933
966
|
if (!live) return;
|
|
934
967
|
live.childExit = code;
|
|
935
968
|
if (this.groupExists(live)) {
|
|
936
|
-
if (!live.terminal) this.requestStop(id, TASK_STATUS.FAILED, `pi exited with code ${code ?? "unknown"} before agent_settled`);
|
|
969
|
+
if (!live.terminal) this.requestStop(id, TASK_STATUS.FAILED, `pi exited with code ${code ?? "unknown"} before agent_settled${this.stderrSuffix(live)}`);
|
|
937
970
|
return;
|
|
938
971
|
}
|
|
939
972
|
this.completeExit(id, live);
|
|
@@ -951,7 +984,7 @@ export class AgentRunner {
|
|
|
951
984
|
return;
|
|
952
985
|
}
|
|
953
986
|
const terminal = live.terminal;
|
|
954
|
-
this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled`, live);
|
|
987
|
+
this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled${this.stderrSuffix(live)}`, live);
|
|
955
988
|
}
|
|
956
989
|
|
|
957
990
|
private finish(id: string, status: TaskRecord["status"], error: string | null, live?: LiveTask): void {
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -971,6 +971,28 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
|
|
|
971
971
|
// this row repeats 2.8.0 exactly. riskEvidence and hint remain dark
|
|
972
972
|
// because neither is proven to reach the negotiated START path Pi consumes.
|
|
973
973
|
"2.8.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
974
|
+
// v2.8.2 shipped OpenCode SDD preflight plugin fixes, community-tools RTK
|
|
975
|
+
// acquisition, and Claude Code Stop telemetry. The provider contract semver
|
|
976
|
+
// stays 1.2.0; the same pin re-mirrors bundle bytes that had drifted under
|
|
977
|
+
// that semver (lens inspection.status "unavailable", targeted-validator
|
|
978
|
+
// regressions/inspection members, seven Pi stop reason codes). None of
|
|
979
|
+
// those touch the closed START/STATUS fields this row negotiates, so it
|
|
980
|
+
// repeats 2.8.1 exactly. riskEvidence and hint remain dark because neither
|
|
981
|
+
// is proven to reach the negotiated START path Pi consumes.
|
|
982
|
+
"2.8.2": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
983
|
+
// v2.9.0 shipped RTK opt-in Community Tool integration (#4560, installer/
|
|
984
|
+
// sync/TUI only), SDD attempt-ledger fixes (#4564, #4567, #4569 — the
|
|
985
|
+
// remediation pointer is now decided by chain equality before shape, and
|
|
986
|
+
// the refusal wording changed), sync telemetry-runtime symlinked root
|
|
987
|
+
// (#4565), OpenCode reviewer Task wrapper decoding (#4545), and Engram
|
|
988
|
+
// protocol asset wording (#4179). Ground-truthed by diffing
|
|
989
|
+
// contracts/review-integration/v2 and contracts/review-provider-contract
|
|
990
|
+
// between the v2.8.2 and v2.9.0 tags in the gentle-ai source tree: zero
|
|
991
|
+
// bytes changed. None of the above touch the closed START/STATUS fields
|
|
992
|
+
// this row negotiates, so it repeats 2.8.2 exactly. riskEvidence and hint
|
|
993
|
+
// remain dark because neither is proven to reach the negotiated START
|
|
994
|
+
// path Pi consumes.
|
|
995
|
+
"2.9.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
974
996
|
});
|
|
975
997
|
|
|
976
998
|
export interface NativeReviewProcessDiagnostics {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.3",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|