@tea-agent/loop-agent 0.39.1 → 0.40.0-next.10
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/CHANGELOG.md +106 -116
- package/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi-executor.js +69 -7
- package/dist/executors/pi-executor.js +4 -2
- package/dist/executors/pi-sdk-executor.js +46 -1
- package/dist/executors/pi-writer-tool-policy.js +57 -1
- package/dist/executors/shell-executor.js +42 -2
- package/dist/executors/workspace-write-snapshot.js +68 -0
- package/dist/infrastructure/harness/atomic-write.js +23 -0
- package/dist/task/config-types.js +10 -0
- package/dist/worker/console/chat/chat-event-store.js +75 -5
- package/dist/worker/console/chat/context-panel.js +7 -0
- package/dist/worker/console/chat/deferred-turn.js +12 -0
- package/dist/worker/console/chat/pi-runtime.js +331 -18
- package/dist/worker/console/chat/provider-error.js +98 -0
- package/dist/worker/console/chat/routes.js +389 -56
- package/dist/worker/console/chat/session-stats.js +179 -0
- package/dist/worker/console/chat/session-store.js +93 -63
- package/dist/worker/console/chat/todos.js +115 -0
- package/dist/worker/console/chat/tool-preview.js +42 -0
- package/dist/worker/console/chat/turn-process.js +20 -55
- package/dist/worker/console/chat/user-questions.js +266 -0
- package/dist/worker/console/chat/workspace-landing.js +79 -0
- package/dist/worker/console/native-directory-picker.js +13 -0
- package/dist/worker/console/routes.js +309 -1
- package/dist/worker/console/security.js +4 -4
- package/dist/worker/console/server.js +529 -186
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CTx1TrNa.js → abnfDiagram-N423BO3Z-Bs-CDAXM.js} +1 -1
- package/dist/worker/console/static/assets/{arc-B4i9cVxX.js → arc-CAkA3We3.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BAXE8_Rl.js → architectureDiagram-T3A2C74G-DEmI_zqr.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C5Xh59at.js → blockDiagram-VBNYF7ZC-uDHANebE.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B_asOzAu.js → c4Diagram-5PPSVZJV-Bv97b4_B.js} +1 -1
- package/dist/worker/console/static/assets/channel-amxUpk7o.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-wRO_iRNc.js → chunk-2GRJ4B5K-shmQKsFz.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ByOaSh_G.js → chunk-2Q5K7J3B-BAraNvcN.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLzudan.js → chunk-5RXB4S5H-BirhxUop.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-JCNGkXYT.js → chunk-5VM5RSS4-C9LgUJpR.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BhrPXXMZ.js → chunk-6Q2QTUOP-DrU-s-t7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-TJ5JuEBT.js → chunk-GF5L2VYU-Tx-H_FA2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-VVthn_mD.js → chunk-JWPE2WC7-BjLtNFrR.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D60-Mny4.js → chunk-KBJHAD2P-UNLVwSsy.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-zXv7aVye.js → chunk-RYQCIY6F-CBEUX8XD.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-By9VQoi1.js → chunk-XXDRQBXY-WTL7IZOq.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C4zQLe-M.js → cose-bilkent-JH36ORCC-CkHG6W3s.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-wh5N2ReN.js → cynefin-VYW2F7L2-BRzFqTaL.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dlu5wIqs.js → cynefinDiagram-MW4NZA55-2_CFp_i7.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CrDgthc6.js → dagre-VZM6K2ZE-BJtsmdkE.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-zN1clg27.js → diagram-7IWD3JNH-DNEFFo6w.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BN-o7J7-.js → diagram-B4RE2ZJO-B_IWV7eo.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C8SuPt0d.js → diagram-LBJQPF4R-DvvRx2dz.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BoizuHbZ.js → diagram-Q27KOJAE-CsibsWLg.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-BzUzXixG.js → diagram-UB23O5K3-les_djBQ.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js → ebnfDiagram-BXEA7PRR-BEm2n7qJ.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-GALUzRbs.js → erDiagram-JOGREHBK-BI7vHHVm.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZJN65Sb.js → flowDiagram-UKHOOZJN-CUFCERrB.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-JLB9OESl.js → ganttDiagram-PKOTCBZU-wxyLiGWt.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CcvCr4GD.js → gitGraphDiagram-DS77QQ5N-Bt897K8Z.js} +1 -1
- package/dist/worker/console/static/assets/index-BHhUOTri.css +1 -0
- package/dist/worker/console/static/assets/index-DqJbO3-p.js +407 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-HG5z7aVg.js → infoDiagram-6WML65LV-DYNx_IPv.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BHEu9chR.js → ishikawaDiagram-WSZJBQD7-CzpLNEu6.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Cp_Up8-a.js → journeyDiagram-NVQOT4AX-97f9owNU.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DvVeY-em.js → kanban-definition-27J2QSJJ-BxM9hyBn.js} +1 -1
- package/dist/worker/console/static/assets/{linear-aGmcjgTn.js → linear-ByuxcHvp.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-Biyo-tqA.js → mermaid.core-DwPGvpCv.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DyxY2pIb.js → mindmap-definition-FAOFIHXS-DCoqG9RT.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkdGuc50.js → pegDiagram-VL7TDLO6-BLSBmBMx.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-NNQI9xVR.js → pieDiagram-7S7Q4E2Y-B0t_0oJW.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-PAJkuz_G.js → quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-ZwrQod0O.js → railroadDiagram-AXF67PYL-B3jRdATC.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D3ZfrzM2.js → requirementDiagram-LRYGKXZP-D-cTtXEN.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DDGFmWk5.js → sankeyDiagram-W5VNT64P-B25igJRE.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DO6mmXTA.js → sequenceDiagram-SI44F4Z6-jW_lM1_D.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DMbCQiJt.js → sizeCapture-X5ZJPWSS-CrzTgsHE.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Di_YUEfC.js → stateDiagram-OKZ733FA-B01MvlqE.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Bn2PphNJ.js → swimlanes-SLNWSIFB-YcF12FiU.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DPh8pRsp.js → timeline-definition-Z64GVDOM-ClnRcg2s.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-GMfl26pW.js → vennDiagram-T6HMQDX7-B6gr_z-q.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DTlmVrmF.js → wardleyDiagram-T6FBY63Y-CvSgguGH.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YGBjKAS5.js → xychartDiagram-ELKLHX3M-23OtSZCb.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +29 -4
- package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
- package/dist/worker/console/static-src/app/useOperatorActions.js +36 -6
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
- package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
- package/dist/worker/console/static-src/app/useTaskWizard.js +2 -2
- package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
- package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
- package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
- package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
- package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +159 -44
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
- package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
- package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
- package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
- package/dist/worker/console/workspace-context.js +234 -0
- package/dist/worker/console/workspace-registry.js +214 -0
- package/dist/worker/observe/server.js +4 -0
- package/dist/worker/observe/static/api.js +26 -6
- package/dist/worker/observe/static/app.js +18 -70
- package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
- package/dist/worker/observe/static/dag-node-purpose.js +203 -0
- package/dist/worker/observe/static/index.html +1 -1
- package/dist/worker/observe/static/inspect-workspace.js +307 -0
- package/dist/worker/observe/static/operator-chrome.css +145 -20
- package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
- package/dist/worker/observe/static/operator-chrome.js +330 -100
- package/dist/worker/observe/static/router.d.ts +46 -0
- package/dist/worker/observe/static/router.js +25 -1
- package/dist/worker/observe/static/state.d.ts +50 -0
- package/dist/worker/observe/static/state.js +174 -2
- package/dist/worker/observe/static/styles.css +348 -38
- package/dist/worker/observe/static/views/dag-graph.js +4 -2
- package/dist/worker/observe/static/views/dag-inspector.js +310 -156
- package/dist/worker/observe/static/views/dag.d.ts +13 -0
- package/dist/worker/observe/static/views/dag.js +6 -4
- package/dist/worker/observe/static/views/dashboard.js +6 -4
- package/dist/worker/observe/static/views/night.js +2 -2
- package/dist/worker/observe/static/views/pool.js +1 -0
- package/dist/worker/observe/static/views/run.js +3 -1
- package/dist/worker/observe/static/views/task.js +1 -1
- package/dist/workflows/dag/backend-test-pytest-collection.js +11 -10
- package/dist/workflows/dag/backend-test-writer-completeness.js +7 -12
- package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
- package/dist/workflows/dag/init-hybrid.js +113 -47
- package/dist/workflows/dag/interrupt-request.js +3 -3
- package/dist/workflows/dag/lifecycle.js +3 -3
- package/dist/workflows/dag/types.js +20 -1
- package/docs/README.md +1 -0
- package/docs/operations/local-development-environment.md +21 -0
- package/docs/templates/agent-dag.schema.json +18 -0
- package/docs/templates/backend-test-dag.json +265 -237
- package/harness.json +3 -3
- package/package.json +3 -1
- package/dist/worker/console/static/assets/channel-Dj_4Moei.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +0 -1
- package/dist/worker/console/static/assets/index-CW88Fjbw.js +0 -406
- package/dist/worker/console/static/assets/index-DP0ZJl39.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +0 -8
- package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
- package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
|
@@ -154,6 +154,28 @@ function assertMkdirRelative(relative, ctx) {
|
|
|
154
154
|
throw new PiWriterPathDeniedError("forbidden", relative || ".");
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
|
+
export async function assertReadablePath(targetPath, ctx) {
|
|
158
|
+
const raw = String(targetPath ?? "").trim();
|
|
159
|
+
if (!raw)
|
|
160
|
+
throw new PiWriterPathDeniedError("empty-path", targetPath ?? "");
|
|
161
|
+
const repoRoot = path.resolve(ctx.repoRoot);
|
|
162
|
+
const absoluteTarget = path.isAbsolute(raw)
|
|
163
|
+
? path.resolve(raw)
|
|
164
|
+
: path.resolve(repoRoot, raw);
|
|
165
|
+
if (!isPathInside(repoRoot, absoluteTarget)) {
|
|
166
|
+
throw new PiWriterPathDeniedError("outside-repo", raw);
|
|
167
|
+
}
|
|
168
|
+
const effective = await resolveEffectiveMutationTarget(repoRoot, absoluteTarget);
|
|
169
|
+
const relative = repoRelativePath(repoRoot, absoluteTarget, raw, false);
|
|
170
|
+
const effectiveRelative = repoRelativePath(effective.realRoot, effective.absoluteTarget, raw, false);
|
|
171
|
+
const readSet = ctx.readSet ?? [];
|
|
172
|
+
for (const candidate of [relative, effectiveRelative]) {
|
|
173
|
+
if (readSet.length === 0 || !matchesAnyPattern(candidate, readSet)) {
|
|
174
|
+
throw new PiWriterPathDeniedError("read-outside-read-set", candidate, "path is outside the node readSet");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return relative;
|
|
178
|
+
}
|
|
157
179
|
/**
|
|
158
180
|
* Assert a mutation target is writable under the DAG writer policy.
|
|
159
181
|
* Returns the normalized lexical repo-relative path on success.
|
|
@@ -201,11 +223,30 @@ export async function assertMkdirPath(dirPath, ctx) {
|
|
|
201
223
|
assertMkdirRelative(effectiveRelative, ctx);
|
|
202
224
|
return lexicalRelative;
|
|
203
225
|
}
|
|
226
|
+
export function createPiReaderFsOperations(ctx) {
|
|
227
|
+
return {
|
|
228
|
+
readFile: async (absolutePath) => {
|
|
229
|
+
await assertReadablePath(absolutePath, ctx);
|
|
230
|
+
return fsReadFile(absolutePath);
|
|
231
|
+
},
|
|
232
|
+
access: async (absolutePath) => {
|
|
233
|
+
await assertReadablePath(absolutePath, ctx);
|
|
234
|
+
await fsAccess(absolutePath);
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
204
238
|
/** Build FS operations that enforce writer policy before mutations. */
|
|
205
239
|
export function createPiWriterFsOperations(ctx) {
|
|
240
|
+
const strictReadSet = (ctx.readSet?.length ?? 0) > 0;
|
|
206
241
|
return {
|
|
207
|
-
readFile: (absolutePath) =>
|
|
242
|
+
readFile: async (absolutePath) => {
|
|
243
|
+
if (strictReadSet)
|
|
244
|
+
await assertReadablePath(absolutePath, ctx);
|
|
245
|
+
return fsReadFile(absolutePath);
|
|
246
|
+
},
|
|
208
247
|
access: async (absolutePath) => {
|
|
248
|
+
if (strictReadSet)
|
|
249
|
+
await assertReadablePath(absolutePath, ctx);
|
|
209
250
|
await fsAccess(absolutePath);
|
|
210
251
|
},
|
|
211
252
|
writeFile: async (absolutePath, content) => {
|
|
@@ -218,6 +259,20 @@ export function createPiWriterFsOperations(ctx) {
|
|
|
218
259
|
},
|
|
219
260
|
};
|
|
220
261
|
}
|
|
262
|
+
export async function createPiReaderCustomTools(ctx) {
|
|
263
|
+
const sdk = (await import("@earendil-works/pi-coding-agent"));
|
|
264
|
+
const createReadToolDefinition = sdk.createReadToolDefinition;
|
|
265
|
+
if (typeof createReadToolDefinition !== "function") {
|
|
266
|
+
throw new Error("pi read tool policy unavailable: createReadToolDefinition missing from SDK");
|
|
267
|
+
}
|
|
268
|
+
const read = createReadToolDefinition(path.resolve(ctx.repoRoot), {
|
|
269
|
+
operations: createPiReaderFsOperations(ctx),
|
|
270
|
+
});
|
|
271
|
+
if (read?.name !== "read") {
|
|
272
|
+
throw new Error("pi read tool policy unavailable: SDK tool must be named read");
|
|
273
|
+
}
|
|
274
|
+
return [read];
|
|
275
|
+
}
|
|
221
276
|
/**
|
|
222
277
|
* Create same-name custom edit/write tool definitions that override built-ins
|
|
223
278
|
* via Pi SDK customTools registration. Fails closed when SDK factories are missing.
|
|
@@ -262,5 +317,6 @@ export function buildPiWriterToolPolicyContext(input) {
|
|
|
262
317
|
writeSet: input.writeSet ?? [],
|
|
263
318
|
forbiddenPaths: input.forbiddenPaths ?? [],
|
|
264
319
|
writePolicy: input.writePolicy ?? "exclusive",
|
|
320
|
+
readSet: input.readSet ?? [],
|
|
265
321
|
};
|
|
266
322
|
}
|
|
@@ -41,6 +41,7 @@ import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
|
|
|
41
41
|
import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
|
|
42
42
|
import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
|
|
43
43
|
import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
44
|
+
import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
|
|
44
45
|
import { parseAndValidateFinalWriteSetApproval, } from "../workflows/dag/node-execution.js";
|
|
45
46
|
import { buildShellProcessEnv } from "./shell-verification.js";
|
|
46
47
|
import { readRunState } from "../workflows/dag/run-store.js";
|
|
@@ -539,13 +540,22 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
539
540
|
const started = Date.now();
|
|
540
541
|
// Plan A: prefer the frozen layout carried by the compiled spec; fall back
|
|
541
542
|
// to the historical default for hand-written legacy DAGs.
|
|
542
|
-
const
|
|
543
|
+
const baseBackendLayout = resolveBackendTestLayout(meta.spec.backendTestLayout
|
|
543
544
|
? {
|
|
544
545
|
testRoot: meta.spec.backendTestLayout.testRoot,
|
|
545
546
|
markdownRel: meta.spec.backendTestLayout.markdownRel,
|
|
546
547
|
scriptRel: meta.spec.backendTestLayout.scriptRel,
|
|
547
548
|
}
|
|
548
549
|
: undefined);
|
|
550
|
+
const markdownPlanArtifact = path.join(meta.runDir, "generate-backend-md-plan-pi", "plan.md");
|
|
551
|
+
const backendLayout = existsSync(markdownPlanArtifact)
|
|
552
|
+
? {
|
|
553
|
+
...baseBackendLayout,
|
|
554
|
+
readmePath: path
|
|
555
|
+
.relative(input.cwd, markdownPlanArtifact)
|
|
556
|
+
.replaceAll(path.sep, "/"),
|
|
557
|
+
}
|
|
558
|
+
: baseBackendLayout;
|
|
549
559
|
try {
|
|
550
560
|
if (!pipeline)
|
|
551
561
|
throw new Error("missing backend-test pipeline id");
|
|
@@ -915,6 +925,7 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
915
925
|
effective = await materializeEffectiveBackendPytestCollection({
|
|
916
926
|
workspaceRoot: input.cwd,
|
|
917
927
|
initial,
|
|
928
|
+
layout: backendLayout,
|
|
918
929
|
});
|
|
919
930
|
}
|
|
920
931
|
else if (initial.status === "REPAIRABLE") {
|
|
@@ -974,6 +985,7 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
974
985
|
workspaceRoot: input.cwd,
|
|
975
986
|
initial,
|
|
976
987
|
final: finalFacts,
|
|
988
|
+
layout: backendLayout,
|
|
977
989
|
});
|
|
978
990
|
}
|
|
979
991
|
else {
|
|
@@ -1036,6 +1048,7 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1036
1048
|
scenarioParamRepairAttempt: scenarioFacts.repairAttempt,
|
|
1037
1049
|
eligibility,
|
|
1038
1050
|
eligibilityInputHashes,
|
|
1051
|
+
layout: backendLayout,
|
|
1039
1052
|
});
|
|
1040
1053
|
return {
|
|
1041
1054
|
ok: true,
|
|
@@ -1144,7 +1157,7 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1144
1157
|
}
|
|
1145
1158
|
else if (pipeline === "markdown-execute-html") {
|
|
1146
1159
|
const executionReadiness = await readBackendTestExecutionReadiness(path.join(meta.runDir, "contracts", "backend-test-execution-readiness.json"));
|
|
1147
|
-
await assertBackendTestExecutionReadinessFresh(input.cwd, executionReadiness);
|
|
1160
|
+
await assertBackendTestExecutionReadinessFresh(input.cwd, executionReadiness, backendLayout);
|
|
1148
1161
|
const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd, backendLayout);
|
|
1149
1162
|
let eligibleItemIds = executionReadiness.eligibleItemIds;
|
|
1150
1163
|
// Plan D gap-fill executeScope=affected: when a gap plan exists in
|
|
@@ -1719,6 +1732,33 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1719
1732
|
}
|
|
1720
1733
|
}
|
|
1721
1734
|
async function executeBackendTestPipelineWithWriteGuard(input, meta) {
|
|
1735
|
+
if (meta.spec.backendTestWorkspaceControl === "filesystem-only") {
|
|
1736
|
+
const paths = input.task.writeSet ?? input.task.allowedPaths;
|
|
1737
|
+
let beforeSnapshot;
|
|
1738
|
+
try {
|
|
1739
|
+
beforeSnapshot = await captureWorkspaceWriteSnapshot({ rootCwd: input.cwd, paths });
|
|
1740
|
+
}
|
|
1741
|
+
catch (error) {
|
|
1742
|
+
return { ok: false, stdout: "", stderr: `workspace filesystem baseline unavailable: ${error instanceof Error ? error.message : String(error)}`, failureCategory: "write-guard", durationMs: 0 };
|
|
1743
|
+
}
|
|
1744
|
+
const result = await executeBackendTestPipeline(input, meta);
|
|
1745
|
+
try {
|
|
1746
|
+
const afterSnapshot = await captureWorkspaceWriteSnapshot({ rootCwd: input.cwd, paths });
|
|
1747
|
+
const guard = validateShellWriteGuard({
|
|
1748
|
+
changedPaths: diffWorkspaceWriteSnapshots(beforeSnapshot, afterSnapshot),
|
|
1749
|
+
writePolicy: input.task.writePolicy,
|
|
1750
|
+
writeSet: input.task.writeSet,
|
|
1751
|
+
allowedPaths: input.task.allowedPaths,
|
|
1752
|
+
forbiddenPaths: input.task.forbiddenPaths,
|
|
1753
|
+
});
|
|
1754
|
+
if (!guard.ok)
|
|
1755
|
+
return { ...result, ok: false, stderr: [result.stderr, `filesystem write guard failed: ${guard.violations.join(", ")}`].filter(Boolean).join("\n\n"), failureCategory: "write-guard" };
|
|
1756
|
+
return result;
|
|
1757
|
+
}
|
|
1758
|
+
catch (error) {
|
|
1759
|
+
return { ...result, ok: false, stderr: [result.stderr, `filesystem write guard failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("\n\n"), failureCategory: "write-guard" };
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1722
1762
|
let beforeStatus;
|
|
1723
1763
|
try {
|
|
1724
1764
|
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
5
|
+
const normalize = (value) => value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
6
|
+
function rootBeforeWildcard(pattern) {
|
|
7
|
+
const value = normalize(pattern);
|
|
8
|
+
const wildcard = value.search(/[?*]/);
|
|
9
|
+
if (wildcard < 0)
|
|
10
|
+
return path.posix.dirname(value);
|
|
11
|
+
const prefix = value.slice(0, wildcard);
|
|
12
|
+
return prefix.slice(0, prefix.lastIndexOf("/")) || ".";
|
|
13
|
+
}
|
|
14
|
+
async function walk(rootCwd, relative, files) {
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = await readdir(path.resolve(rootCwd, relative), { withFileTypes: true });
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if ([".git", ".harness", "node_modules"].includes(entry.name))
|
|
24
|
+
continue;
|
|
25
|
+
const child = normalize(path.posix.join(relative, entry.name));
|
|
26
|
+
if (entry.isDirectory())
|
|
27
|
+
await walk(rootCwd, child, files);
|
|
28
|
+
else if (entry.isFile())
|
|
29
|
+
files.add(child);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function captureWorkspaceWriteSnapshot(input) {
|
|
33
|
+
const candidates = new Set();
|
|
34
|
+
for (const pattern of input.paths) {
|
|
35
|
+
await walk(input.rootCwd, rootBeforeWildcard(pattern), candidates);
|
|
36
|
+
if (!/[?*]/.test(pattern)) {
|
|
37
|
+
try {
|
|
38
|
+
if ((await lstat(path.resolve(input.rootCwd, pattern))).isFile())
|
|
39
|
+
candidates.add(normalize(pattern));
|
|
40
|
+
}
|
|
41
|
+
catch { /* absent target */ }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const files = {};
|
|
45
|
+
let totalBytes = 0;
|
|
46
|
+
for (const relative of [...candidates].filter((file) => input.paths.some((pattern) => pathMatchesPattern(file, pattern))).sort()) {
|
|
47
|
+
if (Object.keys(files).length >= 20_000)
|
|
48
|
+
throw new Error("workspace snapshot file limit exceeded");
|
|
49
|
+
const absolute = path.resolve(input.rootCwd, relative);
|
|
50
|
+
const stat = await lstat(absolute);
|
|
51
|
+
if (!stat.isFile())
|
|
52
|
+
throw new Error(`workspace snapshot encountered non-regular file: ${relative}`);
|
|
53
|
+
totalBytes += stat.size;
|
|
54
|
+
if (totalBytes > 64 * 1024 * 1024)
|
|
55
|
+
throw new Error("workspace snapshot byte limit exceeded");
|
|
56
|
+
files[relative] = { size: stat.size, sha256: createHash("sha256").update(await readFile(absolute)).digest("hex") };
|
|
57
|
+
}
|
|
58
|
+
const body = JSON.stringify(files);
|
|
59
|
+
return { schemaVersion: 1, controlMode: "filesystem-only", files, sha256: createHash("sha256").update(body).digest("hex") };
|
|
60
|
+
}
|
|
61
|
+
export function diffWorkspaceWriteSnapshots(before, after) {
|
|
62
|
+
const paths = new Set([...Object.keys(before.files), ...Object.keys(after.files)]);
|
|
63
|
+
return [...paths].sort().filter((file) => {
|
|
64
|
+
const left = before.files[file];
|
|
65
|
+
const right = after.files[file];
|
|
66
|
+
return !left || !right || left.size !== right.size || left.sha256 !== right.sha256;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -2,6 +2,29 @@ import { copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/pr
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { assertHarnessWriteAllowed, } from "./completed-facts-guard.js";
|
|
4
4
|
let atomicWriteSequence = 0;
|
|
5
|
+
function isTransientJsonReadError(error) {
|
|
6
|
+
if (error instanceof SyntaxError)
|
|
7
|
+
return true;
|
|
8
|
+
if (!error || typeof error !== "object" || !("code" in error))
|
|
9
|
+
return false;
|
|
10
|
+
const code = String(error.code ?? "");
|
|
11
|
+
return code === "EBUSY" || code === "EPERM" || code === "EACCES";
|
|
12
|
+
}
|
|
13
|
+
export async function readJsonWithTransientRetry(targetPath, options = {}) {
|
|
14
|
+
const attempts = Math.max(1, Math.floor(options.attempts ?? 6));
|
|
15
|
+
const delayMs = Math.max(0, Math.floor(options.delayMs ?? 20));
|
|
16
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(await readFile(targetPath, "utf-8"));
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (attempt === attempts || !isTransientJsonReadError(error))
|
|
22
|
+
throw error;
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
throw new Error("unreachable transient JSON read state");
|
|
27
|
+
}
|
|
5
28
|
async function writeAtomic(targetPath, content, options) {
|
|
6
29
|
assertHarnessWriteAllowed(targetPath, options);
|
|
7
30
|
const resolved = options?.repoRoot && !path.isAbsolute(targetPath)
|
|
@@ -147,6 +147,8 @@ export const backendTestConfigSchema = z
|
|
|
147
147
|
gapDoc: z.string().min(1).optional(),
|
|
148
148
|
/** gap-fill pytest execution scope: only affected items (default) or all eligible. */
|
|
149
149
|
executeScope: z.enum(["affected", "all"]).optional(),
|
|
150
|
+
/** Backend-test-only workspace mutation control; omitted means Git guard. */
|
|
151
|
+
workspaceControl: z.enum(["git", "filesystem-only"]).optional(),
|
|
150
152
|
})
|
|
151
153
|
.strict()
|
|
152
154
|
.optional();
|
|
@@ -237,5 +239,13 @@ export const taskConfigSchema = z.preprocess((raw) => {
|
|
|
237
239
|
if ("executor" in obj || "cursorModel" in obj) {
|
|
238
240
|
throw new Error(CURSOR_TASK_FIELD_REMOVED_ERROR);
|
|
239
241
|
}
|
|
242
|
+
const backendTest = obj.backendTest;
|
|
243
|
+
if (backendTest &&
|
|
244
|
+
typeof backendTest === "object" &&
|
|
245
|
+
!Array.isArray(backendTest) &&
|
|
246
|
+
"workspaceControl" in backendTest &&
|
|
247
|
+
obj.taskKind !== "backend-test") {
|
|
248
|
+
throw new Error("backendTest.workspaceControl is only valid when taskKind is backend-test");
|
|
249
|
+
}
|
|
240
250
|
return raw;
|
|
241
251
|
}, taskConfigObjectSchema);
|
|
@@ -231,6 +231,10 @@ export function projectCompactSnapshot(value) {
|
|
|
231
231
|
Number.isFinite(record.tokensBefore)
|
|
232
232
|
? Math.max(0, Math.floor(record.tokensBefore))
|
|
233
233
|
: undefined;
|
|
234
|
+
const estimatedTokensAfter = typeof record.estimatedTokensAfter === "number" &&
|
|
235
|
+
Number.isFinite(record.estimatedTokensAfter)
|
|
236
|
+
? Math.max(0, Math.floor(record.estimatedTokensAfter))
|
|
237
|
+
: undefined;
|
|
234
238
|
const details = record.details === undefined
|
|
235
239
|
? undefined
|
|
236
240
|
: boundedRedactedJson(record.details).preview;
|
|
@@ -239,9 +243,16 @@ export function projectCompactSnapshot(value) {
|
|
|
239
243
|
summary,
|
|
240
244
|
...(firstKeptEntryId ? { firstKeptEntryId } : {}),
|
|
241
245
|
...(tokensBefore !== undefined ? { tokensBefore } : {}),
|
|
246
|
+
...(estimatedTokensAfter !== undefined ? { estimatedTokensAfter } : {}),
|
|
242
247
|
...(details ? { details } : {}),
|
|
243
248
|
previewHash: createHash("sha256")
|
|
244
|
-
.update(JSON.stringify({
|
|
249
|
+
.update(JSON.stringify({
|
|
250
|
+
summary,
|
|
251
|
+
firstKeptEntryId,
|
|
252
|
+
tokensBefore,
|
|
253
|
+
estimatedTokensAfter,
|
|
254
|
+
details,
|
|
255
|
+
}))
|
|
245
256
|
.digest("hex"),
|
|
246
257
|
};
|
|
247
258
|
}
|
|
@@ -433,6 +444,20 @@ function safeSessionId(sessionId) {
|
|
|
433
444
|
* Link durable Chat refs to the existing canonical operation store/event ring.
|
|
434
445
|
* This owns no operation state and schedules no work; it only projects facts.
|
|
435
446
|
*/
|
|
447
|
+
/** R8 AC-R8-001: operationIds whose operation-ref has been overshadowed by
|
|
448
|
+
* a later operation-unlinked tombstone in the same ring. A tombstoned
|
|
449
|
+
* association must never be restored by recoverSession / replay / fork. */
|
|
450
|
+
function tombstonedOperationIds(events) {
|
|
451
|
+
const tombstones = new Set();
|
|
452
|
+
for (const event of events) {
|
|
453
|
+
if (event.kind !== "operation-unlinked")
|
|
454
|
+
continue;
|
|
455
|
+
const operationId = event.data.operationId;
|
|
456
|
+
if (typeof operationId === "string" && operationId)
|
|
457
|
+
tombstones.add(operationId);
|
|
458
|
+
}
|
|
459
|
+
return tombstones;
|
|
460
|
+
}
|
|
436
461
|
export function createChatOperationLinker(options) {
|
|
437
462
|
const followers = new Map();
|
|
438
463
|
const keyFor = (sessionId, operationId) => `${sessionId}\u0000${operationId}`;
|
|
@@ -535,14 +560,37 @@ export function createChatOperationLinker(options) {
|
|
|
535
560
|
}
|
|
536
561
|
return {
|
|
537
562
|
link,
|
|
563
|
+
async unlink(input) {
|
|
564
|
+
// Idempotent removal (AC-R7-001): drop the live follower and the
|
|
565
|
+
// persisted operation-ref event so the association is truly gone.
|
|
566
|
+
const key = keyFor(input.sessionId, input.operationId);
|
|
567
|
+
followers.get(key)?.();
|
|
568
|
+
followers.delete(key);
|
|
569
|
+
const ring = options.chatEvents.snapshot(input.sessionId);
|
|
570
|
+
const refs = ring.filter((event) => event.kind === "operation-ref" &&
|
|
571
|
+
event.data.operationId ===
|
|
572
|
+
input.operationId);
|
|
573
|
+
for (const event of refs) {
|
|
574
|
+
options.chatEvents.append(input.sessionId, event.turnId, {
|
|
575
|
+
kind: "operation-unlinked",
|
|
576
|
+
data: { operationId: input.operationId, unlinkedAt: new Date().toISOString() },
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return refs.length > 0;
|
|
580
|
+
},
|
|
538
581
|
async recoverSession(sessionId) {
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
582
|
+
const snapshot = options.chatEvents.snapshot(sessionId);
|
|
583
|
+
// R8 AC-R8-001: a tombstoned (unlinked) operationId must NOT be
|
|
584
|
+
// re-linked on recovery — the persisted operation-unlinked event
|
|
585
|
+
// overshadows any earlier operation-ref in the same ring.
|
|
586
|
+
const tombstones = tombstonedOperationIds(snapshot);
|
|
587
|
+
const refs = snapshot.filter((event) => event.kind === "operation-ref");
|
|
542
588
|
for (const event of refs) {
|
|
543
589
|
const data = event.data;
|
|
544
590
|
if (!data.operationId || !data.toolCallId)
|
|
545
591
|
continue;
|
|
592
|
+
if (tombstones.has(data.operationId))
|
|
593
|
+
continue;
|
|
546
594
|
await link({
|
|
547
595
|
sessionId,
|
|
548
596
|
turnId: event.turnId,
|
|
@@ -566,6 +614,19 @@ export function createChatOperationLinker(options) {
|
|
|
566
614
|
},
|
|
567
615
|
};
|
|
568
616
|
}
|
|
617
|
+
/** R8 AC-R8-001 (fork): same tombstone scan as the linker — kept local to
|
|
618
|
+
* the store so forkContextRefs needs no linker dependency. */
|
|
619
|
+
function forkTombstonedOperationIds(events) {
|
|
620
|
+
const tombstones = new Set();
|
|
621
|
+
for (const event of events) {
|
|
622
|
+
if (event.kind !== "operation-unlinked")
|
|
623
|
+
continue;
|
|
624
|
+
const operationId = event.data.operationId;
|
|
625
|
+
if (typeof operationId === "string" && operationId)
|
|
626
|
+
tombstones.add(operationId);
|
|
627
|
+
}
|
|
628
|
+
return tombstones;
|
|
629
|
+
}
|
|
569
630
|
export function createChatEventStore(options) {
|
|
570
631
|
const maxEvents = options?.maxEventsPerSession ?? DEFAULT_MAX_EVENTS;
|
|
571
632
|
const maxBytes = options?.maxBytesPerSession ?? DEFAULT_MAX_BYTES;
|
|
@@ -735,13 +796,22 @@ export function createChatEventStore(options) {
|
|
|
735
796
|
"assessment",
|
|
736
797
|
"taskKind-confirmed",
|
|
737
798
|
"human-gate-card",
|
|
799
|
+
"compaction_end",
|
|
738
800
|
"compact",
|
|
739
801
|
"artifact-ref",
|
|
740
802
|
]);
|
|
741
803
|
let copiedCount = 0;
|
|
742
|
-
|
|
804
|
+
const source = this.snapshot(input.sourceSessionId);
|
|
805
|
+
// R8 AC-R8-001: fork excludes tombstoned associations — an unlinked
|
|
806
|
+
// operationId must not leak its operation-ref into a forked session.
|
|
807
|
+
const tombstones = forkTombstonedOperationIds(source);
|
|
808
|
+
for (const event of source) {
|
|
743
809
|
if (!allowed.has(event.kind))
|
|
744
810
|
continue;
|
|
811
|
+
if ((event.kind === "operation-ref" ||
|
|
812
|
+
event.kind === "operation-status") &&
|
|
813
|
+
tombstones.has(event.data.operationId ?? ""))
|
|
814
|
+
continue;
|
|
745
815
|
const data = structuredClone(event.data);
|
|
746
816
|
if (event.kind === "human-gate-card") {
|
|
747
817
|
delete data.humanGateToken;
|
|
@@ -38,6 +38,13 @@ export function projectTaskContext(input) {
|
|
|
38
38
|
const previous = gates.get(card.cardId);
|
|
39
39
|
gates.set(card.cardId, safeGate({ ...card, ...(previous?.dagSpine && !card.dagSpine ? { dagSpine: previous.dagSpine } : {}) }));
|
|
40
40
|
}
|
|
41
|
+
else if (event.kind === "operation-unlinked") {
|
|
42
|
+
// R8 AC-001/AC-R8-001: tombstone — an operation-unlinked event appended
|
|
43
|
+
// after its operation-ref removes the card from the projection for good.
|
|
44
|
+
const operationId = typeof data.operationId === "string" ? data.operationId : "";
|
|
45
|
+
if (operationId)
|
|
46
|
+
view.operations = view.operations.filter((operation) => operation.operationId !== operationId);
|
|
47
|
+
}
|
|
41
48
|
else if (event.kind === "operation-ref" || event.kind === "operation-status") {
|
|
42
49
|
const operation = operationFromEventPayload(data);
|
|
43
50
|
if (operation && (!activeTaskId || !operation.taskId || operation.taskId === activeTaskId))
|
|
@@ -81,11 +81,23 @@ export function projectDeferredTurnSummary(deferredTurn) {
|
|
|
81
81
|
blockedByTurnId: deferredTurn.blockedByTurnId,
|
|
82
82
|
state: deferredTurn.state,
|
|
83
83
|
updatedAt: deferredTurn.updatedAt,
|
|
84
|
+
text: deferredTurn.text,
|
|
84
85
|
textPreview: deferredTurn.text.replace(/\s+/g, " ").trim().slice(0, 120),
|
|
85
86
|
imageCount: deferredTurn.images.length,
|
|
87
|
+
images: structuredClone(deferredTurn.images),
|
|
86
88
|
...(deferredTurn.acceptedTurnId
|
|
87
89
|
? { acceptedTurnId: deferredTurn.acceptedTurnId }
|
|
88
90
|
: {}),
|
|
89
91
|
...(deferredTurn.lastError ? { lastError: deferredTurn.lastError } : {}),
|
|
90
92
|
};
|
|
91
93
|
}
|
|
94
|
+
export function parseDeferredTurns(value) {
|
|
95
|
+
if (!Array.isArray(value))
|
|
96
|
+
return [];
|
|
97
|
+
return value
|
|
98
|
+
.map((item) => parseDeferredTurn(item))
|
|
99
|
+
.filter((item) => item !== undefined);
|
|
100
|
+
}
|
|
101
|
+
export function projectDeferredTurnSummaries(deferredTurns) {
|
|
102
|
+
return deferredTurns.map((item) => projectDeferredTurnSummary(item));
|
|
103
|
+
}
|