@abelxiaoxing/cadence 1.2.2 → 1.2.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.
@@ -87,7 +87,6 @@ export type ActivitySummary = EvidenceActivitySummary | DiffActivitySummary;
87
87
  export type ActivityTone = "accent" | "success" | "warning" | "muted" | "error";
88
88
 
89
89
  export interface ActivityDisplay {
90
- version: 1;
91
90
  kind: "activityDisplay";
92
91
  requestId: string;
93
92
  role: string;
@@ -188,6 +187,7 @@ export function sanitizeDisplayText(value: unknown, maxLength = 240): string {
188
187
  }
189
188
 
190
189
  const SAFE_WORKFLOW_CODE = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
190
+ const SAFE_DESIGN_DETAIL_VALUE = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
191
191
  const SAFE_CONTROL_ACTIONS = new Set([
192
192
  "start",
193
193
  "status",
@@ -221,6 +221,82 @@ export function sanitizeFailureReason(value: unknown, maxLength = 240): string {
221
221
  return safe.slice(0, Math.max(0, maxLength));
222
222
  }
223
223
 
224
+ function safeDesignFailure(value: unknown): {
225
+ operation: string;
226
+ code: string;
227
+ diagnostics: Array<Record<string, string>>;
228
+ } | null {
229
+ const failure = asRecord(value);
230
+ if (
231
+ failure?.kind !== "design-control-failure" ||
232
+ typeof failure.operation !== "string" ||
233
+ !SAFE_DESIGN_DETAIL_VALUE.test(failure.operation) ||
234
+ typeof failure.code !== "string" ||
235
+ !SAFE_DESIGN_DETAIL_VALUE.test(failure.code) ||
236
+ !Array.isArray(failure.diagnostics)
237
+ ) {
238
+ return null;
239
+ }
240
+ const diagnostics = failure.diagnostics.flatMap((candidate) => {
241
+ const record = asRecord(candidate);
242
+ if (
243
+ !record ||
244
+ typeof record.code !== "string" ||
245
+ !SAFE_DESIGN_DETAIL_VALUE.test(record.code)
246
+ ) {
247
+ return [];
248
+ }
249
+ return [
250
+ Object.fromEntries(
251
+ Object.entries(record).filter(
252
+ ([, entry]) =>
253
+ typeof entry === "string" && SAFE_DESIGN_DETAIL_VALUE.test(entry),
254
+ ),
255
+ ) as Record<string, string>,
256
+ ];
257
+ });
258
+ return {
259
+ operation: failure.operation,
260
+ code: failure.code,
261
+ diagnostics,
262
+ };
263
+ }
264
+
265
+ function renderDesignFailure(
266
+ failure: ReturnType<typeof safeDesignFailure> & object,
267
+ expanded: boolean,
268
+ theme?: ActivityTheme,
269
+ ): Component {
270
+ const title = styled(
271
+ theme,
272
+ "error",
273
+ `${dispatchLabel("design")} ${failure.operation} failed: ${failure.code}`,
274
+ );
275
+ if (!expanded || failure.diagnostics.length === 0) {
276
+ const suffix =
277
+ failure.diagnostics.length > 0
278
+ ? styled(
279
+ theme,
280
+ "muted",
281
+ ` (${failure.diagnostics.length} diagnostic${failure.diagnostics.length === 1 ? "" : "s"})`,
282
+ )
283
+ : "";
284
+ return new Text(`${title}${suffix}`, 0, 0);
285
+ }
286
+ const lines = failure.diagnostics.map((diagnostic) => {
287
+ const details = Object.entries(diagnostic)
288
+ .filter(([key]) => key !== "code")
289
+ .map(([key, value]) => `${key}=${value}`)
290
+ .join(" · ");
291
+ return ` - ${diagnostic.code}${details ? ` · ${details}` : ""}`;
292
+ });
293
+ return new Text(
294
+ [title, ...lines.map((entry) => styled(theme, "muted", entry))].join("\n"),
295
+ 0,
296
+ 0,
297
+ );
298
+ }
299
+
224
300
  export function formatElapsed(milliseconds: number): string {
225
301
  const value = Math.max(
226
302
  0,
@@ -470,6 +546,7 @@ function workflowStateOf(
470
546
  }
471
547
  const state = result.state;
472
548
  switch (state) {
549
+ case "not-started":
473
550
  case "created":
474
551
  case "ready":
475
552
  case "queued":
@@ -659,7 +736,6 @@ export function projectWorkflowActivity(
659
736
  ? "completion-state-inconsistent"
660
737
  : workflowCode(payload);
661
738
  return {
662
- version: 1,
663
739
  kind: "activityDisplay",
664
740
  requestId: sanitizeDisplayText(
665
741
  runId ?? taskId ?? change ?? command.operationId ?? "pending",
@@ -792,7 +868,6 @@ function displayFromEvent(
792
868
  ): ActivityDisplay {
793
869
  const reason = event.failureReason;
794
870
  return {
795
- version: 1,
796
871
  kind: "activityDisplay",
797
872
  requestId: entry.requestId,
798
873
  role: entry.role,
@@ -989,7 +1064,6 @@ export class ActivityController {
989
1064
  const entry = this.entries.get(toolCallId);
990
1065
  if (!entry) return;
991
1066
  const display: ActivityDisplay = {
992
- version: 1,
993
1067
  kind: "activityDisplay",
994
1068
  requestId: entry.requestId,
995
1069
  role: entry.role,
@@ -1230,7 +1304,6 @@ export function renderActivityResult(
1230
1304
  if (failedRun) {
1231
1305
  const snapshot = identityFromDispatchArgs(asRecord(context?.args)?.request);
1232
1306
  const display: ActivityDisplay = {
1233
- version: 1,
1234
1307
  kind: "activityDisplay",
1235
1308
  requestId: isActivityDisplay(activity)
1236
1309
  ? activity.requestId
@@ -1250,6 +1323,10 @@ export function renderActivityResult(
1250
1323
  return new ActivityInlineComponent(display, theme);
1251
1324
  }
1252
1325
  if (failed) {
1326
+ const designFailure = safeDesignFailure(details?.designFailure);
1327
+ if (args?.action === "design" && designFailure) {
1328
+ return renderDesignFailure(designFailure, options.expanded, theme);
1329
+ }
1253
1330
  return new Text(
1254
1331
  `${dispatchLabel(args?.action ?? args?.command)} failed`,
1255
1332
  0,
@@ -1275,7 +1352,6 @@ function isActivityDisplay(value: unknown): value is ActivityDisplay {
1275
1352
  const record = asRecord(value);
1276
1353
  return (
1277
1354
  record?.kind === "activityDisplay" &&
1278
- record.version === 1 &&
1279
1355
  typeof record.requestId === "string" &&
1280
1356
  typeof record.role === "string" &&
1281
1357
  typeof record.phase === "string" &&
@@ -11,6 +11,7 @@ import {
11
11
  type EvidenceResult,
12
12
  isAgentsPath,
13
13
  isValidRelativePath,
14
+ LIMITS,
14
15
  type SubmitFinalCategory,
15
16
  type SubmitSchemaState,
16
17
  validateDiffResult,
@@ -427,7 +428,7 @@ export function classifyCandidateContextRequest(
427
428
  classification = { kind: "paused", code: "agents-context-needed" };
428
429
  } else {
429
430
  classification = {
430
- kind: "paused",
431
+ kind: request.code === "approved-context-needed" ? "retryable" : "paused",
431
432
  code:
432
433
  request.code === "task-split-needed"
433
434
  ? "task-split-needed"
@@ -477,10 +478,16 @@ export function createCandidateArtifactTool(
477
478
  executionMode: "sequential",
478
479
  parameters,
479
480
  async execute(_toolCallId, params) {
481
+ attempts++;
480
482
  if (result !== undefined) {
483
+ schema = "invalid";
484
+ failure = {
485
+ kind: "artifact",
486
+ code: "invalid-structural-result",
487
+ stage: "structural-submit",
488
+ };
481
489
  throw new Error("candidate terminal result already submitted");
482
490
  }
483
- attempts++;
484
491
  if (attempts > 2) {
485
492
  schema = "invalid";
486
493
  failure = {
@@ -654,6 +661,170 @@ export function createCandidateArtifactTool(
654
661
  };
655
662
  }
656
663
 
664
+ export function createStructuredPatchTool(input: {
665
+ requestId: string;
666
+ taskId?: string;
667
+ role: string;
668
+ phase: string;
669
+ workspaceRoot: string;
670
+ writePaths: string[];
671
+ deletePaths: string[];
672
+ }) {
673
+ let submitted: DiffResult | CandidateContextRequest | undefined;
674
+ let failure: CandidateFailure | undefined;
675
+ let attempts = 0;
676
+ let schema: SubmitSchemaState = "not-submitted";
677
+ let identity: IdentityOutcome = {
678
+ request: true,
679
+ role: true,
680
+ task: true,
681
+ phase: true,
682
+ };
683
+ const candidateId = input.requestId;
684
+ const tool = defineTool({
685
+ name: "abel_submit_result",
686
+ label: "Submit Abel Structured Patch",
687
+ description:
688
+ "Submit one complete structured patch. Unified-diff headers and hunks are generated by the trusted control plane.",
689
+ executionMode: "sequential",
690
+ parameters: candidateArtifactSchema(candidateId),
691
+ async execute(_toolCallId, params) {
692
+ attempts += 1;
693
+ if (submitted !== undefined) {
694
+ schema = "invalid";
695
+ failure = {
696
+ kind: "artifact",
697
+ code: "invalid-structural-result",
698
+ stage: "structural-submit",
699
+ };
700
+ throw new Error("structured patch terminal result already submitted");
701
+ }
702
+ if (attempts > 2) {
703
+ schema = "invalid";
704
+ failure = {
705
+ kind: "artifact",
706
+ code: "invalid-structural-result",
707
+ stage: "structural-submit",
708
+ };
709
+ throw new Error("structural submission correction limit exceeded");
710
+ }
711
+ identity = {
712
+ request: true,
713
+ role: true,
714
+ task: true,
715
+ phase: true,
716
+ };
717
+ failure = undefined;
718
+ const value = params as unknown as Record<string, unknown>;
719
+ if (value.candidateId !== candidateId) {
720
+ identity.request = false;
721
+ schema = "invalid";
722
+ failure = {
723
+ kind: "artifact",
724
+ code: "structural-identity-mismatch",
725
+ stage: "structural-submit",
726
+ };
727
+ throw new Error("diagnosis candidate identity mismatch");
728
+ }
729
+ if (value.kind === "context-request") {
730
+ let refs: CandidateContextRef[];
731
+ try {
732
+ refs = normalizeCandidateContextRefs(value.refs);
733
+ } catch (error) {
734
+ schema = "invalid";
735
+ failure = {
736
+ kind: "artifact",
737
+ code: "invalid-structural-result",
738
+ stage: "structural-submit",
739
+ };
740
+ throw error;
741
+ }
742
+ submitted = {
743
+ kind: "context-request",
744
+ candidateId,
745
+ code: value.code as CandidateContextRequest["code"],
746
+ refs,
747
+ };
748
+ schema = "valid";
749
+ return {
750
+ content: [
751
+ { type: "text" as const, text: "Context request accepted." },
752
+ ],
753
+ details: { accepted: true },
754
+ terminate: true,
755
+ };
756
+ }
757
+ if (value.kind !== "candidate-patch") {
758
+ schema = "invalid";
759
+ failure = {
760
+ kind: "artifact",
761
+ code: "invalid-structural-result",
762
+ stage: "structural-submit",
763
+ };
764
+ throw new Error("diagnosis result must be a patch or context request");
765
+ }
766
+ let compiled: ReturnType<typeof compileCandidatePatch>;
767
+ try {
768
+ compiled = compileCandidatePatch({
769
+ root: input.workspaceRoot,
770
+ operations: value.operations as CandidatePatchOperation[],
771
+ writePaths: input.writePaths,
772
+ deletePaths: input.deletePaths,
773
+ maxBytes: LIMITS.maxCompleteResultBytes,
774
+ });
775
+ } catch (error) {
776
+ schema = "invalid";
777
+ failure = {
778
+ kind: "artifact",
779
+ code: "invalid-diff",
780
+ stage: "candidate-diff",
781
+ };
782
+ throw error;
783
+ }
784
+ const result: DiffResult = {
785
+ id: input.requestId,
786
+ role: input.role,
787
+ kind: "diff",
788
+ taskId: input.taskId ?? input.requestId,
789
+ phase: input.phase,
790
+ summary:
791
+ "Trusted control plane generated this diff from structured operations.",
792
+ diff: compiled,
793
+ expectedVerification: "parent-owned verification contract",
794
+ risks: [],
795
+ contractCompliant: true,
796
+ };
797
+ const validation = validateDiffResult(result);
798
+ if (!validation.ok) {
799
+ schema = "invalid";
800
+ failure = validation.failure ?? {
801
+ kind: "artifact",
802
+ code: "invalid-structural-result",
803
+ stage: "structural-submit",
804
+ };
805
+ throw new Error(validation.reason ?? "structured patch is invalid");
806
+ }
807
+ submitted = result;
808
+ schema = "valid";
809
+ return {
810
+ content: [{ type: "text" as const, text: "Abel patch accepted." }],
811
+ details: { accepted: true },
812
+ terminate: true,
813
+ };
814
+ },
815
+ });
816
+ return {
817
+ tool,
818
+ getResult: () =>
819
+ submitted === undefined ? undefined : structuredClone(submitted),
820
+ getAttempts: () => attempts,
821
+ getSchema: () => schema,
822
+ getIdentity: () => ({ ...identity }),
823
+ getFailure: () =>
824
+ failure === undefined ? undefined : structuredClone(failure),
825
+ };
826
+ }
827
+
657
828
  export type FinalCategory = SubmitFinalCategory;
658
829
 
659
830
  export interface IdentityOutcome {
@@ -702,6 +873,15 @@ export function createSubmitTool(input: {
702
873
  : diffSchema,
703
874
  async execute(_toolCallId, params) {
704
875
  attempts++;
876
+ if (submitted !== undefined) {
877
+ schema = "invalid";
878
+ failure = {
879
+ kind: "artifact",
880
+ code: "invalid-structural-result",
881
+ stage: "structural-submit",
882
+ };
883
+ throw new Error("duplicate structural submission");
884
+ }
705
885
  if (attempts > 2) {
706
886
  schema = "invalid";
707
887
  failure = {
@@ -768,14 +948,6 @@ export function createSubmitTool(input: {
768
948
  validation.reason ?? "submitted result identity does not match",
769
949
  );
770
950
  }
771
- if (submitted) {
772
- failure = {
773
- kind: "artifact",
774
- code: "invalid-structural-result",
775
- stage: "structural-submit",
776
- };
777
- throw new Error("duplicate structural submission");
778
- }
779
951
  submitted = structuredClone(value) as unknown as
780
952
  | EvidenceResult
781
953
  | DiffResult;
@@ -230,7 +230,7 @@ function hashEvent(
230
230
  eventJson: string,
231
231
  ): string {
232
232
  return createHash("sha256")
233
- .update("cadence-task-ledger-event-v2\0")
233
+ .update("cadence-task-ledger-event\0")
234
234
  .update(priorHash)
235
235
  .update("\0")
236
236
  .update(String(ordinal))
@@ -1354,7 +1354,6 @@ export class TaskLedger {
1354
1354
  return { ordinal: row.ordinal, ...fact };
1355
1355
  });
1356
1356
  const projection = {
1357
- version: 2,
1358
1357
  runId: input.runId,
1359
1358
  taskId: input.taskId,
1360
1359
  deliveryRevision: task.delivery_revision,
@@ -7,12 +7,12 @@ import {
7
7
  } from "./route-policy.ts";
8
8
 
9
9
  export const ROUTE_ATTEMPT_BOUNDS = Object.freeze({
10
- connectMs: 5_000,
11
- firstResponseMs: 30_000,
12
- idleMs: 60_000,
13
- totalMs: 10 * 60_000,
10
+ connectMs: 10_000,
11
+ firstResponseMs: 90_000,
12
+ idleMs: 3 * 60_000,
13
+ totalMs: 20 * 60_000,
14
14
  cooldownMs: 30_000,
15
- attemptsPerRoute: 1,
15
+ attemptsPerRoute: 2,
16
16
  });
17
17
 
18
18
  export type RouteHealthState = "healthy" | "open" | "half-open";
@@ -61,6 +61,7 @@ export interface RunWorkerAttemptInput<T> {
61
61
  requirements?: RouteRequirements;
62
62
  signal?: AbortSignal;
63
63
  onActivity?: (event: BrokerActivityUpdate) => void;
64
+ classifyResult?: (value: T) => string | undefined;
64
65
  execute(context: RouteAttemptContext): Promise<T>;
65
66
  }
66
67
 
@@ -218,6 +219,16 @@ export class WorkerBroker {
218
219
  this.#setRouteHealth(routeId, { state: "healthy" });
219
220
  }
220
221
 
222
+ #releaseHalfOpenProbeForRetry(routeId: string, code: string): void {
223
+ const health = this.#routeHealth(routeId);
224
+ if (health.state !== "half-open" || !health.probeInFlight) return;
225
+ this.#setRouteHealth(routeId, {
226
+ state: "open",
227
+ retryAt: this.#now(),
228
+ lastCode: code,
229
+ });
230
+ }
231
+
221
232
  select(input: {
222
233
  role: WorkerRole;
223
234
  dialects?: RouteDialect[];
@@ -372,6 +383,7 @@ export class WorkerBroker {
372
383
  requirements?: RouteRequirements;
373
384
  signal?: AbortSignal;
374
385
  onActivity?: (event: BrokerActivityUpdate) => void;
386
+ classifyResult?: (value: T) => string | undefined;
375
387
  execute: (context: RouteAttemptContext) => Promise<T>;
376
388
  }): Promise<
377
389
  | { ok: true; routeId: string; value: T; attempts: BrokerAttemptEvidence[] }
@@ -397,12 +409,20 @@ export class WorkerBroker {
397
409
  }
398
410
  const attempted = new Set<string>();
399
411
  const evidence: BrokerAttemptEvidence[] = [];
400
- const maxAttempts = this.#policy.roles[input.role].filter((routeId) => {
401
- const route = this.#policy.routes[routeId];
402
- return route
403
- ? capable(route, input.role, input.requirements ?? {})
404
- : false;
405
- }).length;
412
+ const capableRouteCount = this.#policy.roles[input.role].filter(
413
+ (routeId) => {
414
+ const route = this.#policy.routes[routeId];
415
+ return route
416
+ ? capable(route, input.role, input.requirements ?? {})
417
+ : false;
418
+ },
419
+ ).length;
420
+ const maxAttempts = Math.max(
421
+ 1,
422
+ capableRouteCount > 1
423
+ ? capableRouteCount
424
+ : ROUTE_ATTEMPT_BOUNDS.attemptsPerRoute,
425
+ );
406
426
  for (;;) {
407
427
  const selection = this.select({
408
428
  role: input.role,
@@ -427,6 +447,9 @@ export class WorkerBroker {
427
447
  probeExpiresAt: this.#now() + ROUTE_ATTEMPT_BOUNDS.totalMs,
428
448
  });
429
449
  }
450
+ const routeAttempt = evidence.filter(
451
+ (item) => item.routeId === routeId,
452
+ ).length;
430
453
  const attempt = evidence.length + 1;
431
454
  emitBrokerActivity(input.onActivity, {
432
455
  state: "connecting",
@@ -450,6 +473,31 @@ export class WorkerBroker {
450
473
  : "worker-progress",
451
474
  }),
452
475
  );
476
+ const semanticCode = input.classifyResult?.(value);
477
+ if (semanticCode) {
478
+ evidence.push({ routeId, code: semanticCode });
479
+ const routeHasRetry =
480
+ capableRouteCount === 1 &&
481
+ routeAttempt + 1 < ROUTE_ATTEMPT_BOUNDS.attemptsPerRoute;
482
+ if (routeHasRetry) {
483
+ this.#releaseHalfOpenProbeForRetry(routeId, semanticCode);
484
+ attempted.delete(routeId);
485
+ } else {
486
+ this.markFailure(routeId, semanticCode);
487
+ }
488
+ if (attempt < maxAttempts) {
489
+ emitBrokerActivity(input.onActivity, {
490
+ state: "retrying",
491
+ attempt,
492
+ maxAttempts,
493
+ code: semanticCode,
494
+ wait: "bounded-policy",
495
+ });
496
+ continue;
497
+ }
498
+ if (routeHasRetry) this.markFailure(routeId, semanticCode);
499
+ return { ok: true, routeId, value, attempts: evidence };
500
+ }
453
501
  this.markSuccess(routeId);
454
502
  return { ok: true, routeId, value, attempts: evidence };
455
503
  } catch (error) {
@@ -466,8 +514,16 @@ export class WorkerBroker {
466
514
  }
467
515
  return { ok: false, state: "cancelled", code, attempts: evidence };
468
516
  }
469
- this.markFailure(routeId, code);
470
- if (attempted.size < maxAttempts) {
517
+ const routeHasRetry =
518
+ capableRouteCount === 1 &&
519
+ routeAttempt + 1 < ROUTE_ATTEMPT_BOUNDS.attemptsPerRoute;
520
+ if (routeHasRetry) {
521
+ this.#releaseHalfOpenProbeForRetry(routeId, code);
522
+ attempted.delete(routeId);
523
+ } else {
524
+ this.markFailure(routeId, code);
525
+ }
526
+ if (attempt < maxAttempts) {
471
527
  emitBrokerActivity(input.onActivity, {
472
528
  state: "retrying",
473
529
  attempt,
@@ -475,6 +531,8 @@ export class WorkerBroker {
475
531
  code,
476
532
  wait: "bounded-policy",
477
533
  });
534
+ } else if (routeHasRetry) {
535
+ this.markFailure(routeId, code);
478
536
  }
479
537
  }
480
538
  }
@@ -615,6 +673,7 @@ export class RunWorkerBroker {
615
673
  ...(input.requirements ? { requirements: input.requirements } : {}),
616
674
  ...(input.signal ? { signal: input.signal } : {}),
617
675
  ...(input.onActivity ? { onActivity: input.onActivity } : {}),
676
+ ...(input.classifyResult ? { classifyResult: input.classifyResult } : {}),
618
677
  execute: input.execute,
619
678
  });
620
679
  if (result.ok) {