@abelxiaoxing/cadence 1.0.1 → 1.0.2

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.
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import type { Model, Usage } from "@earendil-works/pi-ai";
2
3
  import {
3
4
  createAgentSession,
@@ -66,9 +67,22 @@ export class UsageAggregator {
66
67
  }
67
68
  }
68
69
 
69
- function wrapScopedTools(roots: string[]) {
70
+ function wrapScopedTools(
71
+ roots: string[],
72
+ cwd: string,
73
+ allowedPaths?: string[],
74
+ ) {
70
75
  const order = ["read", "grep", "find", "ls"];
71
- return createScopedTools({ roots })
76
+ return createScopedTools({
77
+ roots,
78
+ ...(allowedPaths
79
+ ? {
80
+ allowedPaths: allowedPaths.map((relative) =>
81
+ path.resolve(cwd, relative),
82
+ ),
83
+ }
84
+ : {}),
85
+ })
72
86
  .sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name))
73
87
  .map((scoped) =>
74
88
  defineTool({
@@ -100,10 +114,12 @@ export async function runChildSession(input: {
100
114
  model: Model<string>;
101
115
  systemPrompt: string;
102
116
  requestId: string;
117
+ taskId?: string;
103
118
  role: string;
104
119
  phase?: string;
105
120
  output: "evidence" | "diff";
106
121
  roots: string[];
122
+ allowedPaths?: string[];
107
123
  timeoutMs: number;
108
124
  signal?: AbortSignal;
109
125
  }): Promise<
@@ -120,17 +136,19 @@ export async function runChildSession(input: {
120
136
  ok: false;
121
137
  error: string;
122
138
  failureKind: ChildFailureKind;
139
+ transportFailure: boolean;
123
140
  disposeCount: number;
124
141
  classification: SubmitClassification;
125
142
  }
126
143
  > {
127
144
  const submit = createSubmitTool({
128
145
  requestId: input.requestId,
146
+ taskId: input.taskId,
129
147
  role: input.role,
130
148
  phase: input.phase ?? "red",
131
149
  output: input.output,
132
150
  });
133
- const readTools = wrapScopedTools(input.roots);
151
+ const readTools = wrapScopedTools(input.roots, input.cwd, input.allowedPaths);
134
152
  const customTools = [...readTools, submit.tool];
135
153
  const toolNames = customTools.map((tool) => tool.name);
136
154
  let disposeCount = 0;
@@ -185,6 +203,23 @@ export async function runChildSession(input: {
185
203
  identity: submit.getIdentity(),
186
204
  };
187
205
  };
206
+ const transportFailed = (): boolean => {
207
+ const final = session?.messages
208
+ .filter((message) => message.role === "assistant")
209
+ .at(-1);
210
+ if (final === undefined) return true;
211
+ if (final.stopReason === "error") {
212
+ return (
213
+ submit.getAttempts() === 0 &&
214
+ !final.content.some(
215
+ (content) =>
216
+ content.type === "toolCall" &&
217
+ content.name === "abel_submit_result",
218
+ )
219
+ );
220
+ }
221
+ return final.stopReason === "aborted";
222
+ };
188
223
  try {
189
224
  abort.signal.throwIfAborted();
190
225
  const creation = createAgentSession({
@@ -239,11 +274,13 @@ export async function runChildSession(input: {
239
274
  const attempts = submit.getAttempts();
240
275
  const classification = classifySession();
241
276
  if (!result || attempts !== 1) {
277
+ const transportFailure = transportFailed();
242
278
  disposeOnce();
243
279
  return {
244
280
  ok: false,
245
281
  error: "child did not retain exactly one structural submission",
246
282
  failureKind: "failed",
283
+ transportFailure,
247
284
  disposeCount,
248
285
  classification,
249
286
  };
@@ -262,6 +299,7 @@ export async function runChildSession(input: {
262
299
  ok: false,
263
300
  error: "final assistant message is not one structural submit",
264
301
  failureKind: "failed",
302
+ transportFailure: false,
265
303
  disposeCount,
266
304
  classification,
267
305
  };
@@ -277,19 +315,22 @@ export async function runChildSession(input: {
277
315
  classification,
278
316
  };
279
317
  } catch (error) {
280
- disposeOnce();
281
318
  const failureKind: ChildFailureKind = timedOut
282
319
  ? "timed-out"
283
320
  : input.signal !== undefined &&
284
321
  (input.signal.aborted || abort.signal.reason === input.signal.reason)
285
322
  ? "cancelled"
286
323
  : "failed";
324
+ const classification = classifySession();
325
+ const transportFailure = transportFailed();
326
+ disposeOnce();
287
327
  return {
288
328
  ok: false,
289
329
  error: (error as Error).message,
290
330
  failureKind,
331
+ transportFailure,
291
332
  disposeCount,
292
- classification: classifySession(),
333
+ classification,
293
334
  };
294
335
  } finally {
295
336
  clearTimeout(timer);
package/src/contracts.ts CHANGED
@@ -21,6 +21,20 @@ export const PHASES = [
21
21
  "review",
22
22
  ] as const;
23
23
 
24
+ export const RECOVERY_CODES = [
25
+ "mechanical-redispatch-exhausted",
26
+ "implementation-artifact-delivery-blocked",
27
+ "environment-blocked",
28
+ "design-required",
29
+ ] as const;
30
+
31
+ export type RecoveryCode = (typeof RECOVERY_CODES)[number];
32
+ export type RecoveryNext =
33
+ | "finish-unaffected"
34
+ | "correct-artifact"
35
+ | "repair-environment"
36
+ | "return-to-design";
37
+
24
38
  export const LIMITS = {
25
39
  maxActiveChildSessions: 4,
26
40
  maxRequestsPerBatch: 8,
@@ -30,10 +44,20 @@ export const LIMITS = {
30
44
  } as const;
31
45
 
32
46
  const NONCANONICAL = /(^|\/)\.\.(\/|$)|(^|\/)\/|^\//;
47
+ const SNAPSHOT_SHA256 = /^[a-f0-9]{64}$/;
48
+
49
+ export interface VerificationContract {
50
+ id: string;
51
+ argv: string[];
52
+ classification: "expected-red" | "expected-green" | "expected-refactor";
53
+ expectedFailure?: string;
54
+ minTests: number;
55
+ }
33
56
 
34
57
  export interface RequestEnvelope {
35
58
  stage: string;
36
59
  role: string;
60
+ taskId?: string;
37
61
  id: string;
38
62
  phase: string;
39
63
  objective: string;
@@ -47,9 +71,23 @@ export interface RequestEnvelope {
47
71
  verificationLock?: string;
48
72
  };
49
73
  output: string;
74
+ verification?: VerificationContract;
50
75
  snapshot?: unknown;
51
76
  }
52
77
 
78
+ export interface RecoveryRecord {
79
+ code: RecoveryCode;
80
+ taskId: string;
81
+ requestId: string;
82
+ phase: string;
83
+ launchIndex: 0 | 1;
84
+ branchBlocked: true;
85
+ dependentsBlocked: true;
86
+ partialResultUsable: false;
87
+ independentResultsPreserved: true;
88
+ next: RecoveryNext;
89
+ }
90
+
53
91
  export function isValidRelativePath(p: unknown): p is string {
54
92
  return (
55
93
  typeof p === "string" &&
@@ -63,6 +101,115 @@ export function isValidRelativePath(p: unknown): p is string {
63
101
  );
64
102
  }
65
103
 
104
+ export function validateVerificationContract(
105
+ value: unknown,
106
+ ): { ok: true; value: VerificationContract } | { ok: false; reason: string } {
107
+ if (value === null || typeof value !== "object" || Array.isArray(value))
108
+ return { ok: false, reason: "invalid verification contract" };
109
+ const contract = value as Record<string, unknown>;
110
+ if (
111
+ typeof contract.id !== "string" ||
112
+ contract.id.length === 0 ||
113
+ contract.id.length > 128 ||
114
+ !Array.isArray(contract.argv) ||
115
+ contract.argv.some(
116
+ (part) =>
117
+ typeof part !== "string" ||
118
+ part.length === 0 ||
119
+ /[;&|`$<>\n\r]/u.test(part) ||
120
+ part.includes("\0"),
121
+ ) ||
122
+ !["expected-red", "expected-green", "expected-refactor"].includes(
123
+ String(contract.classification),
124
+ ) ||
125
+ !Number.isSafeInteger(contract.minTests) ||
126
+ (contract.minTests as number) < 1
127
+ ) {
128
+ return { ok: false, reason: "invalid verification contract" };
129
+ }
130
+ const argv = contract.argv as string[];
131
+ const check =
132
+ argv.length === 3 &&
133
+ argv[0] === "bun" &&
134
+ argv[1] === "run" &&
135
+ argv[2] === "check";
136
+ const target =
137
+ argv.length >= 4 &&
138
+ argv[0] === "bun" &&
139
+ argv[1] === "run" &&
140
+ argv[2] === "test:target" &&
141
+ argv
142
+ .slice(3)
143
+ .every((part) => part.startsWith("test/") && isValidRelativePath(part));
144
+ if (!check && !target)
145
+ return { ok: false, reason: "verification argv is not approved" };
146
+ if (
147
+ contract.classification === "expected-red"
148
+ ? typeof contract.expectedFailure !== "string" ||
149
+ contract.expectedFailure.length === 0
150
+ : contract.expectedFailure !== undefined
151
+ ) {
152
+ return { ok: false, reason: "invalid expected failure identity" };
153
+ }
154
+ return { ok: true, value: contract as unknown as VerificationContract };
155
+ }
156
+
157
+ function validateImplementationSnapshot(
158
+ snapshot: unknown,
159
+ paths: string[],
160
+ ): string | null {
161
+ if (
162
+ snapshot === undefined ||
163
+ snapshot === null ||
164
+ typeof snapshot !== "object" ||
165
+ Array.isArray(snapshot)
166
+ ) {
167
+ return "invalid snapshot";
168
+ }
169
+ const bounds = snapshot as Record<string, unknown>;
170
+ for (const [path, value] of Object.entries(bounds)) {
171
+ if (
172
+ !isValidRelativePath(path) ||
173
+ value === null ||
174
+ typeof value !== "object" ||
175
+ Array.isArray(value)
176
+ ) {
177
+ return "invalid snapshot";
178
+ }
179
+ const entry = value as Record<string, unknown>;
180
+ if (entry.kind === "file") {
181
+ if (
182
+ typeof entry.sha256 !== "string" ||
183
+ !SNAPSHOT_SHA256.test(entry.sha256) ||
184
+ typeof entry.bytes !== "number" ||
185
+ !Number.isSafeInteger(entry.bytes) ||
186
+ entry.bytes < 0
187
+ ) {
188
+ return "invalid snapshot";
189
+ }
190
+ } else if (entry.kind === "dir") {
191
+ if (
192
+ typeof entry.manifest !== "string" ||
193
+ !SNAPSHOT_SHA256.test(entry.manifest)
194
+ ) {
195
+ return "invalid snapshot";
196
+ }
197
+ } else if (entry.kind === "absent") {
198
+ if (entry.absent !== true) return "invalid snapshot";
199
+ } else {
200
+ return "invalid snapshot";
201
+ }
202
+ }
203
+ if (
204
+ [...new Set(paths)].some(
205
+ (path) => !Object.prototype.hasOwnProperty.call(bounds, path),
206
+ )
207
+ ) {
208
+ return "snapshot does not cover declared paths";
209
+ }
210
+ return null;
211
+ }
212
+
66
213
  export function validateRequestEnvelope(
67
214
  value: unknown,
68
215
  ): { ok: true; value: RequestEnvelope } | { ok: false; reason: string } {
@@ -98,6 +245,23 @@ export function validateRequestEnvelope(
98
245
  ) {
99
246
  return { ok: false, reason: "invalid request id" };
100
247
  }
248
+ if (
249
+ env.taskId !== undefined &&
250
+ (typeof env.taskId !== "string" ||
251
+ env.taskId.length === 0 ||
252
+ env.taskId.length > 128)
253
+ ) {
254
+ return { ok: false, reason: "invalid task id" };
255
+ }
256
+ // Implementation phases use a phase-local request id and carry their stable
257
+ // task identity explicitly. Other packets keep id as task id.
258
+ if (
259
+ env.stage === "abel-implement" &&
260
+ env.output === "diff" &&
261
+ env.taskId === undefined
262
+ ) {
263
+ return { ok: false, reason: "missing required field: taskId" };
264
+ }
101
265
  if (
102
266
  typeof env.phase !== "string" ||
103
267
  !(PHASES as readonly string[]).includes(env.phase)
@@ -155,6 +319,45 @@ export function validateRequestEnvelope(
155
319
  if (!(OUTPUT_KINDS as readonly string[]).includes(env.output as string)) {
156
320
  return { ok: false, reason: `invalid output kind: ${String(env.output)}` };
157
321
  }
322
+ if (env.verification !== undefined) {
323
+ const verification = validateVerificationContract(env.verification);
324
+ if (!verification.ok) {
325
+ return {
326
+ ok: false,
327
+ reason: verification.reason,
328
+ };
329
+ }
330
+ }
331
+ if (env.stage === "abel-implement" && env.output === "diff") {
332
+ if (env.verification === undefined) {
333
+ return { ok: false, reason: "missing required field: verification" };
334
+ }
335
+ const expectedClassification = {
336
+ red: "expected-red",
337
+ green: "expected-green",
338
+ refactor: "expected-refactor",
339
+ }[String(env.phase)];
340
+ if (
341
+ expectedClassification === undefined ||
342
+ (env.verification as VerificationContract).classification !==
343
+ expectedClassification
344
+ ) {
345
+ return {
346
+ ok: false,
347
+ reason:
348
+ "verification classification does not match implementation phase",
349
+ };
350
+ }
351
+ }
352
+ if (env.stage === "abel-implement" && env.output === "diff") {
353
+ const snapshotReason = validateImplementationSnapshot(env.snapshot, [
354
+ ...(decl.read as string[]),
355
+ ...(decl.write as string[]),
356
+ ]);
357
+ if (snapshotReason !== null) {
358
+ return { ok: false, reason: snapshotReason };
359
+ }
360
+ }
158
361
  if (
159
362
  env.snapshot !== undefined &&
160
363
  (env.snapshot === null || typeof env.snapshot !== "object")
@@ -172,68 +375,196 @@ export function diffWritePaths(diffText: string): {
172
375
  if (typeof diffText !== "string" || diffText.length === 0) {
173
376
  throw new Error("empty diff");
174
377
  }
175
- if (/^GIT binary patch|^Binary files /.test(diffText)) {
176
- throw new Error("binary diff is not supported");
177
- }
178
- const lines = diffText.split("\n");
179
- if (lines.some((line) => /^copy (from|to) /.test(line))) {
180
- throw new Error("copy records are not supported");
181
- }
182
- if (lines.some((line) => /^rename (from|to) /.test(line))) {
183
- throw new Error("rename records are not supported");
184
- }
185
- const paths: string[] = [];
186
- let sawHeader = false;
187
- let pendingOld: string | null = null;
378
+ const hasFinalLf = diffText.endsWith("\n");
379
+ const lines = (hasFinalLf ? diffText.slice(0, -1) : diffText).split("\n");
380
+
188
381
  for (const line of lines) {
189
- if (/^old mode /.test(line) || /^new mode /.test(line)) {
382
+ if (/^(?:GIT binary patch|Binary files )/.test(line)) {
383
+ throw new Error("binary diff is not supported");
384
+ }
385
+ if (/^(?:copy|rename) (?:from|to) /.test(line)) {
386
+ throw new Error(`${line.split(" ", 1)[0]} records are not supported`);
387
+ }
388
+ if (/^(?:old|new) mode /.test(line)) {
190
389
  throw new Error("mode transitions are not supported");
191
390
  }
192
- if (/^(new|deleted) file mode /.test(line)) {
193
- const mode = line.split(" ").pop();
194
- if (mode === "160000") {
195
- throw new Error("submodule mode is not supported");
196
- }
197
- if (mode !== "100644" && mode !== "100755") {
198
- throw new Error(`non-regular file mode is not supported: ${mode}`);
391
+ const mode = /^(?:new file mode|deleted file mode) (.+)$/.exec(line);
392
+ if (mode) requireRegularFileMode(mode[1]);
393
+ }
394
+
395
+ const paths: string[] = [];
396
+ let i = 0;
397
+ while (i < lines.length) {
398
+ if (!isFileSectionStart(lines[i])) {
399
+ if (paths.length === 0) {
400
+ throw new Error("diff has no ordinary text headers");
199
401
  }
200
- continue;
402
+ throw new Error(`unconsumed diff content: ${lines[i]}`);
201
403
  }
202
- if (line.startsWith("--- ") && !line.startsWith("--- /dev/null")) {
203
- pendingOld = parseHeaderPath(line.slice(4));
204
- continue;
404
+
405
+ let gitTarget: string | null = null;
406
+ if (lines[i].startsWith("diff --git ")) {
407
+ gitTarget = parseGitTarget(lines[i]);
408
+ i++;
205
409
  }
206
- if (line.startsWith("+++ ")) {
207
- const newPath = parseHeaderPath(line.slice(4));
208
- let target: string;
209
- if (newPath === null) {
210
- if (pendingOld === null) {
211
- throw new Error("diff header pair is malformed");
212
- }
213
- target = pendingOld;
214
- } else {
215
- target = newPath;
216
- if (pendingOld !== null && pendingOld !== newPath) {
217
- throw new Error("mismatched old/new diff targets");
410
+
411
+ let operation: "add" | "delete" | null = null;
412
+ const mode = /^(new file mode|deleted file mode) (.+)$/.exec(
413
+ lines[i] ?? "",
414
+ );
415
+ if (mode) {
416
+ operation = mode[1] === "new file mode" ? "add" : "delete";
417
+ i++;
418
+ }
419
+ if (lines[i]?.startsWith("index ")) {
420
+ parseIndexMetadata(lines[i]);
421
+ i++;
422
+ }
423
+
424
+ if (!lines[i]?.startsWith("--- ")) {
425
+ throw new Error("file section is missing an ordinary --- header");
426
+ }
427
+ const oldPath = parseHeaderPath(lines[i].slice(4), "a");
428
+ i++;
429
+ if (!lines[i]?.startsWith("+++ ")) {
430
+ throw new Error("diff header pair is malformed");
431
+ }
432
+ const newPath = parseHeaderPath(lines[i].slice(4), "b");
433
+ i++;
434
+
435
+ if (oldPath === null && newPath === null) {
436
+ throw new Error("diff header pair has no target");
437
+ }
438
+ if (oldPath !== null && newPath !== null && oldPath !== newPath) {
439
+ throw new Error("mismatched old/new diff targets");
440
+ }
441
+ const target = oldPath ?? newPath;
442
+ if (target === null) throw new Error("diff header pair has no target");
443
+ if (gitTarget !== null && gitTarget !== target) {
444
+ throw new Error("mismatched diff --git and text targets");
445
+ }
446
+ if (
447
+ (operation === "add" && oldPath !== null) ||
448
+ (operation === "delete" && newPath !== null)
449
+ ) {
450
+ throw new Error("create/delete metadata does not match text headers");
451
+ }
452
+ if (paths.includes(target)) throw new Error("duplicate diff targets");
453
+ paths.push(target);
454
+
455
+ let sawHunk = false;
456
+ while (lines[i]?.startsWith("@@")) {
457
+ const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/.exec(
458
+ lines[i],
459
+ );
460
+ if (!hunk) throw new Error(`invalid hunk header: ${lines[i]}`);
461
+ const range = [
462
+ Number(hunk[1]),
463
+ Number(hunk[2] ?? "1"),
464
+ Number(hunk[3]),
465
+ Number(hunk[4] ?? "1"),
466
+ ];
467
+ if (!range.every(Number.isSafeInteger)) {
468
+ throw new Error("hunk range exceeds supported integer bounds");
469
+ }
470
+ let oldRemaining = range[1];
471
+ let newRemaining = range[3];
472
+ sawHunk = true;
473
+ i++;
474
+
475
+ while (oldRemaining > 0 || newRemaining > 0) {
476
+ if (i >= lines.length) throw new Error("hunk body underflow");
477
+ const body = lines[i];
478
+ switch (body[0]) {
479
+ case " ":
480
+ if (oldRemaining === 0 || newRemaining === 0) {
481
+ throw new Error("hunk body overflow");
482
+ }
483
+ oldRemaining--;
484
+ newRemaining--;
485
+ break;
486
+ case "-":
487
+ if (oldRemaining === 0) throw new Error("hunk body overflow");
488
+ oldRemaining--;
489
+ break;
490
+ case "+":
491
+ if (newRemaining === 0) throw new Error("hunk body overflow");
492
+ newRemaining--;
493
+ break;
494
+ default:
495
+ throw new Error("hunk body underflow");
218
496
  }
497
+ i++;
498
+ if (lines[i] === "\") i++;
219
499
  }
220
- pendingOld = null;
221
- if (paths.includes(target)) throw new Error("duplicate diff targets");
222
- paths.push(target);
223
- sawHeader = true;
500
+ }
501
+ if (!sawHunk) {
502
+ throw new Error("file section has no valid unified-diff hunk");
224
503
  }
225
504
  }
226
- if (!sawHeader || paths.length === 0) {
227
- throw new Error("diff has no ordinary text headers");
228
- }
505
+
506
+ if (!hasFinalLf) throw new Error("diff must end with LF");
229
507
  return { paths, kind: "text" };
230
508
  }
231
509
 
232
- function parseHeaderPath(header: string): string | null {
510
+ function isFileSectionStart(line: string | undefined): boolean {
511
+ return (
512
+ line?.startsWith("diff --git ") === true ||
513
+ line?.startsWith("new file mode ") === true ||
514
+ line?.startsWith("deleted file mode ") === true ||
515
+ line?.startsWith("index ") === true ||
516
+ line?.startsWith("--- ") === true
517
+ );
518
+ }
519
+
520
+ function parseGitTarget(line: string): string {
521
+ const prefix = "diff --git a/";
522
+ if (!line.startsWith(prefix)) throw new Error("malformed diff --git header");
523
+ const rest = line.slice(prefix.length);
524
+ for (
525
+ let separator = rest.indexOf(" b/");
526
+ separator >= 0;
527
+ separator = rest.indexOf(" b/", separator + 1)
528
+ ) {
529
+ const oldPath = rest.slice(0, separator);
530
+ const newPath = rest.slice(separator + 3);
531
+ if (oldPath === newPath) return requireCanonicalDiffPath(oldPath);
532
+ }
533
+ throw new Error("mismatched diff --git targets");
534
+ }
535
+
536
+ function parseIndexMetadata(line: string): void {
537
+ const index = /^index [0-9a-f]+\.\.[0-9a-f]+(?: ([0-7]{6}))?$/.exec(line);
538
+ if (!index) throw new Error("malformed index metadata");
539
+ if (index[1]) requireRegularFileMode(index[1]);
540
+ }
541
+
542
+ function requireRegularFileMode(mode: string): void {
543
+ if (mode === "160000") {
544
+ throw new Error("submodule mode is not supported: 160000");
545
+ }
546
+ if (mode !== "100644" && mode !== "100755") {
547
+ throw new Error(`non-regular file mode is not supported: ${mode}`);
548
+ }
549
+ }
550
+
551
+ function parseHeaderPath(header: string, prefix: "a" | "b"): string | null {
233
552
  if (header === "/dev/null") return null;
234
- const path = header.replace(/^\s*(a|b)\//, "");
235
- if (!isValidRelativePath(path)) {
236
- throw new Error(`noncanonical or escaping diff path: ${header}`);
553
+ const other = prefix === "a" ? "b" : "a";
554
+ if (header.startsWith(`${other}/`)) {
555
+ throw new Error("mismatched old/new diff header prefixes");
556
+ }
557
+ const path = header.startsWith(`${prefix}/`) ? header.slice(2) : header;
558
+ return requireCanonicalDiffPath(path);
559
+ }
560
+
561
+ function requireCanonicalDiffPath(path: string): string {
562
+ if (
563
+ !isValidRelativePath(path) ||
564
+ path.trim() !== path ||
565
+ /[\r\n\t]/.test(path)
566
+ ) {
567
+ throw new Error(`noncanonical or escaping diff path: ${path}`);
237
568
  }
238
569
  return path;
239
570
  }