cclaw-cli 6.7.0 → 6.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -326,7 +326,7 @@ function hasPriorAck(events, args, runId) {
326
326
  function usage() {
327
327
  process.stderr.write([
328
328
  "Usage:",
329
- " node .cclaw/hooks/delegation-record.mjs --stage=<stage> --agent=<agent> --mode=<mandatory|proactive> --status=<scheduled|launched|acknowledged|completed|failed|waived|stale> --span-id=<id> [--dispatch-id=<id>] [--worker-run-id=<id>] [--dispatch-surface=<surface>] [--agent-definition-path=<path>] [--ack-ts=<iso>] [--launched-ts=<iso>] [--completed-ts=<iso>] [--evidence-ref=<ref>] [--waiver-reason=<text>] [--json]",
329
+ " node .cclaw/hooks/delegation-record.mjs --stage=<stage> --agent=<agent> --mode=<mandatory|proactive> --status=<scheduled|launched|acknowledged|completed|failed|waived|stale> --span-id=<id> [--dispatch-id=<id>] [--worker-run-id=<id>] [--dispatch-surface=<surface>] [--agent-definition-path=<path>] [--ack-ts=<iso>] [--launched-ts=<iso>] [--completed-ts=<iso>] [--evidence-ref=<ref>] [--waiver-reason=<text>] [--supersede=<prevSpanId>] [--allow-parallel] [--json]",
330
330
  " node .cclaw/hooks/delegation-record.mjs --rerecord --span-id=<id> --dispatch-id=<id> --dispatch-surface=<surface> --agent-definition-path=<path> [--ack-ts=<iso>] [--completed-ts=<iso>] [--evidence-ref=<ref>] [--json]",
331
331
  " node .cclaw/hooks/delegation-record.mjs --repair --span-id=<id> --repair-reason=\"<why>\" [--json]",
332
332
  "",
@@ -335,6 +335,10 @@ function usage() {
335
335
  "",
336
336
  "Per-surface allowed --agent-definition-path prefixes:",
337
337
  ...VALID_DISPATCH_SURFACES.map((surface) => " " + surface + ": " + (SURFACE_PATH_PREFIXES[surface].length === 0 ? "(any)" : SURFACE_PATH_PREFIXES[surface].join(", "))),
338
+ "",
339
+ "Dispatch dedup (v6.8.0):",
340
+ " --supersede=<prevSpanId> close the previous active span on this (stage, agent) as 'stale' before recording the new scheduled row",
341
+ " --allow-parallel record both spans as concurrent; new row is tagged allowParallel: true",
338
342
  ""
339
343
  ].join("\\n") + "\\n");
340
344
  }
@@ -350,6 +354,51 @@ function emitProblems(problems, json, code) {
350
354
  process.exitCode = exitCode;
351
355
  }
352
356
 
357
+ function emitErrorJson(error, details, json) {
358
+ if (json) {
359
+ process.stdout.write(JSON.stringify({ ok: false, error, details }, null, 2) + "\\n");
360
+ } else {
361
+ process.stderr.write("[cclaw] delegation-record: error: " + error + " — " + JSON.stringify(details) + "\\n");
362
+ }
363
+ process.exit(2);
364
+ }
365
+
366
+ // keep in sync with validateMonotonicTimestamps in src/delegation.ts
367
+ function validateMonotonicTimestampsInline(stamped, prior) {
368
+ const startTs = stamped.startTs;
369
+ if (stamped.launchedTs && startTs && stamped.launchedTs < startTs) {
370
+ return { field: "launchedTs", actual: stamped.launchedTs, bound: startTs };
371
+ }
372
+ if (stamped.ackTs) {
373
+ const ackBound = stamped.launchedTs || startTs;
374
+ if (ackBound && stamped.ackTs < ackBound) {
375
+ return { field: "ackTs", actual: stamped.ackTs, bound: ackBound };
376
+ }
377
+ }
378
+ if (stamped.completedTs) {
379
+ const completedBound = stamped.ackTs || stamped.launchedTs || startTs;
380
+ if (completedBound && stamped.completedTs < completedBound) {
381
+ return { field: "completedTs", actual: stamped.completedTs, bound: completedBound };
382
+ }
383
+ }
384
+ if (!stamped.spanId) return null;
385
+ const priorForSpan = (prior || []).filter((entry) => entry && entry.spanId === stamped.spanId);
386
+ if (priorForSpan.length === 0) return null;
387
+ const tsValues = priorForSpan
388
+ .map((entry) => entry.ts || entry.startTs || "")
389
+ .filter((ts) => ts.length > 0);
390
+ if (tsValues.length === 0) return null;
391
+ let latest = tsValues[0];
392
+ for (let i = 1; i < tsValues.length; i += 1) {
393
+ if (tsValues[i] > latest) latest = tsValues[i];
394
+ }
395
+ const stampedTs = stamped.ts || stamped.startTs || "";
396
+ if (stampedTs && stampedTs < latest) {
397
+ return { field: "ts", actual: stampedTs, bound: latest };
398
+ }
399
+ return null;
400
+ }
401
+
353
402
  function normalizeRelPath(value) {
354
403
  return String(value || "").replace(/\\\\/gu, "/").replace(/^\\.\\//u, "");
355
404
  }
@@ -382,12 +431,15 @@ function normalizeEvidenceRefs(args) {
382
431
  return [];
383
432
  }
384
433
 
385
- function buildRow(args, status, runId, now) {
434
+ function buildRow(args, status, runId, now, options) {
386
435
  const fulfillmentMode = args["dispatch-surface"] === "role-switch"
387
436
  ? "role-switch"
388
437
  : args["dispatch-surface"] === "cursor-task" || args["dispatch-surface"] === "generic-task"
389
438
  ? "generic-dispatch"
390
439
  : "isolated";
440
+ // Inherit the span's startTs from prior rows so monotonic validation
441
+ // can compare against the original schedule, not the row write time.
442
+ const startTs = (options && options.spanStartTs) || now;
391
443
  return {
392
444
  stage: args.stage,
393
445
  agent: args.agent,
@@ -402,13 +454,83 @@ function buildRow(args, status, runId, now) {
402
454
  waiverReason: args["waiver-reason"],
403
455
  evidenceRefs: normalizeEvidenceRefs(args),
404
456
  runId,
405
- startTs: now,
457
+ startTs,
406
458
  ts: now,
407
459
  launchedTs: args["launched-ts"] || (status === "launched" ? now : undefined),
408
460
  ackTs: args["ack-ts"] || (status === "acknowledged" ? now : undefined),
409
461
  completedTs: args["completed-ts"] || (status === "completed" ? now : undefined),
410
462
  endTs: TERMINAL.has(status) ? now : undefined,
411
- schemaVersion: LEDGER_SCHEMA_VERSION
463
+ schemaVersion: LEDGER_SCHEMA_VERSION,
464
+ allowParallel: args["allow-parallel"] === true ? true : undefined
465
+ };
466
+ }
467
+
468
+ async function readDelegationLedgerEntries(root) {
469
+ try {
470
+ const raw = await fs.readFile(path.join(root, RUNTIME_ROOT, "state", "delegation-log.json"), "utf8");
471
+ const parsed = JSON.parse(raw);
472
+ if (parsed && Array.isArray(parsed.entries)) return parsed.entries;
473
+ } catch {
474
+ // empty / missing ledger is fine for dedup + monotonicity checks
475
+ }
476
+ return [];
477
+ }
478
+
479
+ // keep in sync with findActiveSpanForPair / DispatchDuplicateError in src/delegation.ts
480
+ function findActiveSpanForPairInline(stage, agent, runId, entries) {
481
+ const ACTIVE_STATUSES = new Set(["scheduled", "launched", "acknowledged"]);
482
+ const effectiveTs = (entry) =>
483
+ entry.completedTs || entry.ackTs || entry.launchedTs || entry.endTs || entry.startTs || entry.ts || "";
484
+ const latestBySpan = new Map();
485
+ for (const entry of entries) {
486
+ if (!entry || typeof entry !== "object") continue;
487
+ if (typeof entry.spanId !== "string" || entry.spanId.length === 0) continue;
488
+ if (entry.runId && entry.runId !== runId) continue;
489
+ if (entry.stage !== stage || entry.agent !== agent) continue;
490
+ const existing = latestBySpan.get(entry.spanId);
491
+ if (!existing || effectiveTs(entry) >= effectiveTs(existing)) {
492
+ latestBySpan.set(entry.spanId, entry);
493
+ }
494
+ }
495
+ for (const entry of latestBySpan.values()) {
496
+ if (ACTIVE_STATUSES.has(entry.status)) return entry;
497
+ }
498
+ return null;
499
+ }
500
+
501
+ function enforceDispatchDedupInline(stamped, priorEntries, args) {
502
+ if (stamped.status !== "scheduled") return null;
503
+ if (args["allow-parallel"] === true) return null;
504
+ const existing = findActiveSpanForPairInline(
505
+ stamped.stage,
506
+ stamped.agent,
507
+ stamped.runId,
508
+ priorEntries
509
+ );
510
+ if (!existing || existing.spanId === stamped.spanId) return null;
511
+ if (typeof args.supersede === "string" && args.supersede.length > 0) {
512
+ if (args.supersede !== existing.spanId) {
513
+ return {
514
+ kind: "supersede-mismatch",
515
+ details: {
516
+ requested: args.supersede,
517
+ actualActiveSpanId: existing.spanId,
518
+ stage: stamped.stage,
519
+ agent: stamped.agent
520
+ }
521
+ };
522
+ }
523
+ return { kind: "supersede", existing };
524
+ }
525
+ return {
526
+ kind: "error",
527
+ details: {
528
+ existingSpanId: existing.spanId,
529
+ existingStatus: existing.status,
530
+ newSpanId: stamped.spanId,
531
+ pair: { stage: stamped.stage, agent: stamped.agent },
532
+ hint: "pass --supersede=" + existing.spanId + " to close the previous span as stale, or --allow-parallel to record both as concurrent"
533
+ }
412
534
  };
413
535
  }
414
536
 
@@ -490,7 +612,32 @@ async function persistEntry(root, runId, clean, event, options = {}) {
490
612
  await releaseDelegationLogLock(lockDir);
491
613
  }
492
614
 
493
- const active = ledger.entries.filter((entry) => ["scheduled", "launched", "acknowledged"].includes(entry.status));
615
+ // keep in sync with computeActiveSubagents in src/delegation.ts
616
+ const ACTIVE_STATUSES = new Set(["scheduled", "launched", "acknowledged"]);
617
+ const effectiveTs = (entry) =>
618
+ entry.completedTs || entry.ackTs || entry.launchedTs || entry.endTs || entry.startTs || entry.ts || "";
619
+ const latestBySpan = new Map();
620
+ for (const entry of ledger.entries) {
621
+ if (!entry || typeof entry !== "object" || typeof entry.spanId !== "string" || entry.spanId.length === 0) continue;
622
+ const existing = latestBySpan.get(entry.spanId);
623
+ if (!existing) {
624
+ latestBySpan.set(entry.spanId, entry);
625
+ continue;
626
+ }
627
+ if (effectiveTs(entry) >= effectiveTs(existing)) {
628
+ latestBySpan.set(entry.spanId, entry);
629
+ }
630
+ }
631
+ const active = [];
632
+ for (const entry of latestBySpan.values()) {
633
+ if (ACTIVE_STATUSES.has(entry.status)) active.push(entry);
634
+ }
635
+ active.sort((a, b) => {
636
+ const aKey = a.startTs || a.ts || "";
637
+ const bKey = b.startTs || b.ts || "";
638
+ if (aKey === bKey) return 0;
639
+ return aKey < bKey ? -1 : 1;
640
+ });
494
641
  await fs.writeFile(path.join(stateDir, "subagents.json"), JSON.stringify({ active, updatedAt: event.eventTs }, null, 2) + "\\n", { encoding: "utf8", mode: 0o600 });
495
642
  }
496
643
 
@@ -814,9 +961,61 @@ async function main() {
814
961
  }
815
962
 
816
963
  const status = args.status;
817
- const row = buildRow(args, status, runId, now);
964
+ const priorLedger = await readDelegationLedgerEntries(root);
965
+ const priorForSpan = priorLedger.filter((e) => e && e.spanId === args["span-id"]);
966
+ const inheritedStartTs = priorForSpan
967
+ .map((e) => e.startTs)
968
+ .filter((ts) => typeof ts === "string" && ts.length > 0)
969
+ .sort()[0];
970
+ // When no prior row exists, fall back to the earliest user-supplied
971
+ // event timestamp so the monotonic validator never sees the row write
972
+ // time overshoot the real event timestamps.
973
+ const lifecycleCandidates = [
974
+ inheritedStartTs,
975
+ args["launched-ts"],
976
+ args["ack-ts"],
977
+ args["completed-ts"],
978
+ now
979
+ ].filter((value) => typeof value === "string" && value.length > 0);
980
+ const spanStartTs = inheritedStartTs ||
981
+ lifecycleCandidates.reduce((min, candidate) => (candidate < min ? candidate : min), now);
982
+ const row = buildRow(args, status, runId, now, { spanStartTs });
818
983
  const clean = Object.fromEntries(Object.entries(row).filter(([, value]) => value !== undefined));
819
984
  const event = { ...clean, event: status, eventTs: now };
985
+
986
+ const violation = validateMonotonicTimestampsInline(clean, priorLedger);
987
+ if (violation) {
988
+ emitErrorJson("delegation_timestamp_non_monotonic", violation, json);
989
+ return;
990
+ }
991
+ const dedupViolation = enforceDispatchDedupInline(clean, priorLedger, args);
992
+ if (dedupViolation) {
993
+ if (dedupViolation.kind === "supersede") {
994
+ const stalenessTs = new Date(new Date(now).getTime() - 1).toISOString();
995
+ const staleRow = {
996
+ stage: dedupViolation.existing.stage,
997
+ agent: dedupViolation.existing.agent,
998
+ mode: dedupViolation.existing.mode,
999
+ status: "stale",
1000
+ spanId: dedupViolation.existing.spanId,
1001
+ runId,
1002
+ startTs: dedupViolation.existing.startTs || stalenessTs,
1003
+ ts: stalenessTs,
1004
+ endTs: stalenessTs,
1005
+ supersededBy: clean.spanId,
1006
+ schemaVersion: LEDGER_SCHEMA_VERSION
1007
+ };
1008
+ const staleEvent = { ...staleRow, event: "stale", eventTs: stalenessTs };
1009
+ await persistEntry(root, runId, staleRow, staleEvent);
1010
+ } else if (dedupViolation.kind === "error") {
1011
+ emitErrorJson("dispatch_duplicate", dedupViolation.details, json);
1012
+ return;
1013
+ } else if (dedupViolation.kind === "supersede-mismatch") {
1014
+ emitErrorJson("dispatch_supersede_mismatch", dedupViolation.details, json);
1015
+ return;
1016
+ }
1017
+ }
1018
+
820
1019
  await persistEntry(root, runId, clean, event);
821
1020
  process.stdout.write(JSON.stringify({ ok: true, event }, null, 2) + "\\n");
822
1021
  }
@@ -236,6 +236,8 @@ ${rows}
236
236
  Mandatory: ${mandatoryList}. Record lifecycle rows in \`${delegationLogRel}\` and append-only \`${delegationEventsRel}\` before completion.${runPhaseLegend}
237
237
  ### Harness Dispatch Contract — use true harness dispatch: Claude Task, Cursor generic dispatch, OpenCode \`.opencode/agents/<agent>.md\` via Task/@agent, Codex \`.codex/agents/<agent>.toml\`. Do not collapse OpenCode or Codex to role-switch by default. Worker ACK Contract: ACK must include \`spanId\`, \`dispatchId\`, \`dispatchSurface\`, \`agentDefinitionPath\`, and \`ackTs\`; never claim \`fulfillmentMode: "isolated"\` without matching lifecycle proof. Canonical helper (same flags as \`delegation-record.mjs --help\`): \`node .cclaw/hooks/delegation-record.mjs --stage=<stage> --agent=<agent> --mode=<mandatory|proactive> --status=<scheduled|launched|acknowledged|completed|...> --span-id=<id> --dispatch-id=<id> --dispatch-surface=<surface> --agent-definition-path=<path> [--ack-ts=<iso>] [--evidence-ref=<ref>] --json\`. Lifecycle order: \`scheduled → launched → acknowledged → completed\` on one span (reuse the same span id); completed isolated/generic rows require a prior ACK event for that span or \`--ack-ts=<iso>\`. For a partial audit trail, \`--repair --span-id=<id> --repair-reason="<why>"\` appends missing phases (see \`--help\`) instead of inventing shortcuts.
238
238
 
239
+ If you must re-dispatch the same agent in the same stage before the previous span has a terminal row, pass \`--supersede=<prevSpanId>\` (closes the previous span as \`stale\` with \`supersededBy=<newSpanId>\`) or \`--allow-parallel\` (records both spans as concurrently active and tags the new row with \`allowParallel: true\`). Without one of those flags, a duplicate scheduled write on the same \`(stage, agent)\` pair fails with \`exit 2\` and \`{ ok: false, error: "dispatch_duplicate" }\`. Lifecycle timestamps are also validated: \`startTs ≤ launchedTs ≤ ackTs ≤ completedTs\` and per-span \`ts\` is non-decreasing — non-monotonic values fail with \`exit 2\` and \`{ ok: false, error: "delegation_timestamp_non_monotonic" }\`.
240
+
239
241
  ${perHarnessLifecycleRecipeBlock()}`;
240
242
  }
241
243
  function perHarnessLifecycleRecipeBlock() {
@@ -116,6 +116,19 @@ export type DelegationEntry = {
116
116
  * `dispatchSurface`, `agentDefinitionPath`, and ACK timestamp
117
117
  */
118
118
  schemaVersion?: 1 | 2 | 3;
119
+ /**
120
+ * v6.8.0 — when set, the operator explicitly opted into running this
121
+ * scheduled span concurrently with another active span on the same
122
+ * `(stage, agent)` pair. Bypasses the dispatch-dedup check.
123
+ */
124
+ allowParallel?: boolean;
125
+ /**
126
+ * v6.8.0 — set on synthetic terminal `stale` rows written via
127
+ * `--supersede=<prevSpanId>`. References the new spanId that
128
+ * superseded this span. Helps `/cc tree` and the linter report a
129
+ * coherent successor chain.
130
+ */
131
+ supersededBy?: string;
119
132
  };
120
133
  export declare const DELEGATION_LEDGER_SCHEMA_VERSION: 3;
121
134
  export type DelegationLedger = {
@@ -144,6 +157,91 @@ export declare function readDelegationEvents(projectRoot: string): Promise<{
144
157
  events: DelegationEvent[];
145
158
  corruptLines: number[];
146
159
  }>;
160
+ /**
161
+ * Fold ledger entries to the latest row per `spanId` and keep only spans
162
+ * whose latest status is still active (`scheduled | launched |
163
+ * acknowledged`). Used by the `state/subagents.json` writer so the
164
+ * tracker never reports a span that already has a terminal row.
165
+ *
166
+ * Output is ordered by ascending `startTs ?? ts` so existing UI
167
+ * consumers see a stable presentation order.
168
+ *
169
+ * Rows without a `spanId` are skipped — they are not addressable by
170
+ * the tracker contract and would collide on the empty key.
171
+ *
172
+ * Callers are expected to pass entries already filtered to the active
173
+ * `runId`; cross-run rows are therefore not re-filtered here.
174
+ *
175
+ * keep in sync with the inline copy in
176
+ * `src/content/hooks.ts::delegationRecordScript`.
177
+ */
178
+ export declare function computeActiveSubagents(entries: DelegationEntry[]): DelegationEntry[];
179
+ /**
180
+ * v6.8.0 — thrown by `validateMonotonicTimestamps` when an incoming row
181
+ * would push a span's timeline backwards. Carries enough context that
182
+ * the CLI / hook surface can format a `delegation_timestamp_non_monotonic`
183
+ * JSON payload without re-deriving the offending field.
184
+ *
185
+ * keep in sync with the inline copy in
186
+ * `src/content/hooks.ts::delegationRecordScript`.
187
+ */
188
+ export declare class DelegationTimestampError extends Error {
189
+ readonly field: string;
190
+ readonly actual: string;
191
+ readonly priorBound: string;
192
+ constructor(field: string, actual: string, priorBound: string);
193
+ }
194
+ /**
195
+ * v6.8.0 — enforce that lifecycle timestamps on a delegation span move
196
+ * forward (or stay equal). Validates both per-row invariants
197
+ * (`startTs ≤ launchedTs ≤ ackTs ≤ completedTs`) and a cross-row
198
+ * invariant: the union of prior rows for this `spanId` plus the
199
+ * incoming row must have non-decreasing `ts`.
200
+ *
201
+ * Equality is allowed because fast-completing dispatches legitimately
202
+ * collapse multiple lifecycle markers onto the same instant.
203
+ *
204
+ * keep in sync with the inline copy in
205
+ * `src/content/hooks.ts::delegationRecordScript`.
206
+ */
207
+ export declare function validateMonotonicTimestamps(stamped: DelegationEntry, prior: DelegationEntry[]): void;
208
+ /**
209
+ * v6.8.0 — thrown by `appendDelegation` when the operator opens a
210
+ * second `scheduled` span on the same `(stage, agent)` pair while an
211
+ * earlier span on the same pair is still active. Callers can catch and
212
+ * either pass the existing span id via `--supersede=<id>` (which
213
+ * pre-writes a synthetic `stale` row) or `--allow-parallel` to record
214
+ * concurrent spans intentionally.
215
+ */
216
+ export declare class DispatchDuplicateError extends Error {
217
+ readonly existingSpanId: string;
218
+ readonly existingStatus: DelegationStatus;
219
+ readonly newSpanId: string;
220
+ readonly pair: {
221
+ stage: string;
222
+ agent: string;
223
+ };
224
+ constructor(params: {
225
+ existingSpanId: string;
226
+ existingStatus: DelegationStatus;
227
+ newSpanId: string;
228
+ pair: {
229
+ stage: string;
230
+ agent: string;
231
+ };
232
+ });
233
+ }
234
+ /**
235
+ * v6.8.0 — find the latest active span for a given `(stage, agent)`
236
+ * pair in the supplied ledger entries. Returns the row whose latest
237
+ * status (after the latest-by-spanId fold) is still in the active set
238
+ * (`scheduled | launched | acknowledged`). Caller is responsible for
239
+ * filtering to the current run.
240
+ *
241
+ * keep in sync with the inline copy in
242
+ * `src/content/hooks.ts::delegationRecordScript`.
243
+ */
244
+ export declare function findActiveSpanForPair(stage: string, agent: string, runId: string, ledger: DelegationLedger): DelegationEntry | null;
147
245
  export declare function appendDelegation(projectRoot: string, entry: DelegationEntry): Promise<void>;
148
246
  /**
149
247
  * Aggregate the fulfillment mode cclaw expects for the active harness set.
@@ -222,7 +222,9 @@ function isDelegationEntry(value) {
222
222
  retryOk &&
223
223
  (o.evidenceRefs === undefined || (Array.isArray(o.evidenceRefs) && o.evidenceRefs.every((item) => typeof item === "string"))) &&
224
224
  (o.skill === undefined || typeof o.skill === "string") &&
225
- (o.schemaVersion === undefined || o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3));
225
+ (o.schemaVersion === undefined || o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3) &&
226
+ (o.allowParallel === undefined || typeof o.allowParallel === "boolean") &&
227
+ (o.supersededBy === undefined || typeof o.supersededBy === "string"));
226
228
  }
227
229
  function isDelegationDispatchSurface(value) {
228
230
  return typeof value === "string" && DELEGATION_DISPATCH_SURFACES.includes(value);
@@ -378,10 +380,196 @@ async function appendDelegationEvent(projectRoot, event) {
378
380
  await fs.mkdir(path.dirname(filePath), { recursive: true });
379
381
  await fs.appendFile(filePath, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
380
382
  }
383
+ /**
384
+ * Effective timestamp used to order rows that share a `spanId`. Newest
385
+ * lifecycle column wins. Returns the empty string when nothing is set
386
+ * so the caller still has a stable lexicographic compare key.
387
+ *
388
+ * keep in sync with the inline copy in
389
+ * `src/content/hooks.ts::delegationRecordScript`.
390
+ */
391
+ function effectiveSpanTs(entry) {
392
+ return entry.completedTs ?? entry.ackTs ?? entry.launchedTs ?? entry.endTs ?? entry.startTs ?? entry.ts ?? "";
393
+ }
394
+ const ACTIVE_DELEGATION_STATUSES = new Set([
395
+ "scheduled",
396
+ "launched",
397
+ "acknowledged"
398
+ ]);
399
+ /**
400
+ * Fold ledger entries to the latest row per `spanId` and keep only spans
401
+ * whose latest status is still active (`scheduled | launched |
402
+ * acknowledged`). Used by the `state/subagents.json` writer so the
403
+ * tracker never reports a span that already has a terminal row.
404
+ *
405
+ * Output is ordered by ascending `startTs ?? ts` so existing UI
406
+ * consumers see a stable presentation order.
407
+ *
408
+ * Rows without a `spanId` are skipped — they are not addressable by
409
+ * the tracker contract and would collide on the empty key.
410
+ *
411
+ * Callers are expected to pass entries already filtered to the active
412
+ * `runId`; cross-run rows are therefore not re-filtered here.
413
+ *
414
+ * keep in sync with the inline copy in
415
+ * `src/content/hooks.ts::delegationRecordScript`.
416
+ */
417
+ export function computeActiveSubagents(entries) {
418
+ const latestBySpan = new Map();
419
+ for (const entry of entries) {
420
+ if (!entry.spanId)
421
+ continue;
422
+ const existing = latestBySpan.get(entry.spanId);
423
+ if (!existing) {
424
+ latestBySpan.set(entry.spanId, entry);
425
+ continue;
426
+ }
427
+ const existingTs = effectiveSpanTs(existing);
428
+ const incomingTs = effectiveSpanTs(entry);
429
+ if (incomingTs >= existingTs) {
430
+ latestBySpan.set(entry.spanId, entry);
431
+ }
432
+ }
433
+ const folded = [];
434
+ for (const entry of latestBySpan.values()) {
435
+ if (ACTIVE_DELEGATION_STATUSES.has(entry.status)) {
436
+ folded.push(entry);
437
+ }
438
+ }
439
+ folded.sort((a, b) => {
440
+ const aKey = a.startTs ?? a.ts ?? "";
441
+ const bKey = b.startTs ?? b.ts ?? "";
442
+ if (aKey === bKey)
443
+ return 0;
444
+ return aKey < bKey ? -1 : 1;
445
+ });
446
+ return folded;
447
+ }
448
+ /**
449
+ * v6.8.0 — thrown by `validateMonotonicTimestamps` when an incoming row
450
+ * would push a span's timeline backwards. Carries enough context that
451
+ * the CLI / hook surface can format a `delegation_timestamp_non_monotonic`
452
+ * JSON payload without re-deriving the offending field.
453
+ *
454
+ * keep in sync with the inline copy in
455
+ * `src/content/hooks.ts::delegationRecordScript`.
456
+ */
457
+ export class DelegationTimestampError extends Error {
458
+ field;
459
+ actual;
460
+ priorBound;
461
+ constructor(field, actual, priorBound) {
462
+ super(`delegation_timestamp_non_monotonic — ${field}: ${actual} < ${priorBound}`);
463
+ this.name = "DelegationTimestampError";
464
+ this.field = field;
465
+ this.actual = actual;
466
+ this.priorBound = priorBound;
467
+ }
468
+ }
469
+ /**
470
+ * v6.8.0 — enforce that lifecycle timestamps on a delegation span move
471
+ * forward (or stay equal). Validates both per-row invariants
472
+ * (`startTs ≤ launchedTs ≤ ackTs ≤ completedTs`) and a cross-row
473
+ * invariant: the union of prior rows for this `spanId` plus the
474
+ * incoming row must have non-decreasing `ts`.
475
+ *
476
+ * Equality is allowed because fast-completing dispatches legitimately
477
+ * collapse multiple lifecycle markers onto the same instant.
478
+ *
479
+ * keep in sync with the inline copy in
480
+ * `src/content/hooks.ts::delegationRecordScript`.
481
+ */
482
+ export function validateMonotonicTimestamps(stamped, prior) {
483
+ const startTs = stamped.startTs;
484
+ if (stamped.launchedTs && startTs && stamped.launchedTs < startTs) {
485
+ throw new DelegationTimestampError("launchedTs", stamped.launchedTs, startTs);
486
+ }
487
+ if (stamped.ackTs) {
488
+ const ackBound = stamped.launchedTs ?? startTs;
489
+ if (ackBound && stamped.ackTs < ackBound) {
490
+ throw new DelegationTimestampError("ackTs", stamped.ackTs, ackBound);
491
+ }
492
+ }
493
+ if (stamped.completedTs) {
494
+ const completedBound = stamped.ackTs ?? stamped.launchedTs ?? startTs;
495
+ if (completedBound && stamped.completedTs < completedBound) {
496
+ throw new DelegationTimestampError("completedTs", stamped.completedTs, completedBound);
497
+ }
498
+ }
499
+ if (!stamped.spanId)
500
+ return;
501
+ const priorForSpan = prior.filter((entry) => entry.spanId === stamped.spanId);
502
+ if (priorForSpan.length === 0)
503
+ return;
504
+ const timeline = [...priorForSpan, stamped]
505
+ .map((entry) => ({ entry, ts: entry.ts ?? entry.startTs ?? "" }))
506
+ .filter((row) => row.ts.length > 0)
507
+ .sort((a, b) => (a.ts === b.ts ? 0 : a.ts < b.ts ? -1 : 1));
508
+ for (let i = 1; i < timeline.length; i += 1) {
509
+ const previous = timeline[i - 1];
510
+ const current = timeline[i];
511
+ if (current.ts < previous.ts) {
512
+ throw new DelegationTimestampError("ts", current.ts, previous.ts);
513
+ }
514
+ }
515
+ // Find the latest existing row by `ts` for the same spanId; if the
516
+ // new row's `ts` is older than that latest, the timeline regressed.
517
+ const latestPrior = priorForSpan
518
+ .map((entry) => entry.ts ?? entry.startTs ?? "")
519
+ .filter((ts) => ts.length > 0)
520
+ .sort()
521
+ .at(-1);
522
+ const stampedTs = stamped.ts ?? stamped.startTs ?? "";
523
+ if (latestPrior && stampedTs && stampedTs < latestPrior) {
524
+ throw new DelegationTimestampError("ts", stampedTs, latestPrior);
525
+ }
526
+ }
527
+ /**
528
+ * v6.8.0 — thrown by `appendDelegation` when the operator opens a
529
+ * second `scheduled` span on the same `(stage, agent)` pair while an
530
+ * earlier span on the same pair is still active. Callers can catch and
531
+ * either pass the existing span id via `--supersede=<id>` (which
532
+ * pre-writes a synthetic `stale` row) or `--allow-parallel` to record
533
+ * concurrent spans intentionally.
534
+ */
535
+ export class DispatchDuplicateError extends Error {
536
+ existingSpanId;
537
+ existingStatus;
538
+ newSpanId;
539
+ pair;
540
+ constructor(params) {
541
+ super(`dispatch_duplicate — already-active spanId=${params.existingSpanId} (status=${params.existingStatus}) on stage=${params.pair.stage}, agent=${params.pair.agent}. ` +
542
+ `pass --supersede=${params.existingSpanId} to close the previous span as stale, or --allow-parallel to record both as concurrent.`);
543
+ this.name = "DispatchDuplicateError";
544
+ this.existingSpanId = params.existingSpanId;
545
+ this.existingStatus = params.existingStatus;
546
+ this.newSpanId = params.newSpanId;
547
+ this.pair = params.pair;
548
+ }
549
+ }
550
+ /**
551
+ * v6.8.0 — find the latest active span for a given `(stage, agent)`
552
+ * pair in the supplied ledger entries. Returns the row whose latest
553
+ * status (after the latest-by-spanId fold) is still in the active set
554
+ * (`scheduled | launched | acknowledged`). Caller is responsible for
555
+ * filtering to the current run.
556
+ *
557
+ * keep in sync with the inline copy in
558
+ * `src/content/hooks.ts::delegationRecordScript`.
559
+ */
560
+ export function findActiveSpanForPair(stage, agent, runId, ledger) {
561
+ const sameRun = ledger.entries.filter((entry) => {
562
+ if (entry.runId && entry.runId !== runId)
563
+ return false;
564
+ return entry.stage === stage && entry.agent === agent;
565
+ });
566
+ for (const entry of computeActiveSubagents(sameRun)) {
567
+ return entry;
568
+ }
569
+ return null;
570
+ }
381
571
  async function writeSubagentTracker(projectRoot, entries) {
382
- const active = entries
383
- .filter((entry) => entry.status === "scheduled" || entry.status === "launched" || entry.status === "acknowledged")
384
- .map((entry) => ({
572
+ const active = computeActiveSubagents(entries).map((entry) => ({
385
573
  spanId: entry.spanId,
386
574
  dispatchId: entry.dispatchId,
387
575
  workerRunId: entry.workerRunId,
@@ -392,7 +580,8 @@ async function writeSubagentTracker(projectRoot, entries) {
392
580
  agentDefinitionPath: entry.agentDefinitionPath,
393
581
  startedAt: entry.startTs,
394
582
  launchedAt: entry.launchedTs,
395
- acknowledgedAt: entry.ackTs
583
+ acknowledgedAt: entry.ackTs,
584
+ allowParallel: entry.allowParallel
396
585
  }));
397
586
  await writeFileSafe(subagentsStatePath(projectRoot), `${JSON.stringify({ active, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
398
587
  }
@@ -401,7 +590,20 @@ export async function appendDelegation(projectRoot, entry) {
401
590
  await withDirectoryLock(delegationLockPath(projectRoot), async () => {
402
591
  const filePath = delegationLogPath(projectRoot);
403
592
  const prior = await readDelegationLedger(projectRoot);
404
- const startTs = entry.startTs ?? entry.ts ?? new Date().toISOString();
593
+ // Span start anchor: prefer explicit `startTs`; otherwise fall back to
594
+ // the earliest provided lifecycle marker so the monotonic validator
595
+ // never sees a synthetic `now` overshoot a real event timestamp.
596
+ const lifecycleCandidates = [
597
+ entry.startTs,
598
+ entry.launchedTs,
599
+ entry.ackTs,
600
+ entry.completedTs,
601
+ entry.ts
602
+ ].filter((value) => typeof value === "string" && value.length > 0);
603
+ const earliestLifecycle = lifecycleCandidates.length > 0
604
+ ? lifecycleCandidates.reduce((min, candidate) => (candidate < min ? candidate : min))
605
+ : undefined;
606
+ const startTs = entry.startTs ?? earliestLifecycle ?? new Date().toISOString();
405
607
  if (entry.status === "waived" && !hasValidWaiverReason(entry.waiverReason)) {
406
608
  throw new Error("waived delegation entries require a non-empty waiverReason");
407
609
  }
@@ -445,6 +647,18 @@ export async function appendDelegation(projectRoot, entry) {
445
647
  if (prior.entries.some((existing) => existing.spanId === stamped.spanId && existing.status === stamped.status)) {
446
648
  return;
447
649
  }
650
+ validateMonotonicTimestamps(stamped, prior.entries);
651
+ if (stamped.status === "scheduled" && stamped.allowParallel !== true) {
652
+ const existing = findActiveSpanForPair(stamped.stage, stamped.agent, activeRunId, prior);
653
+ if (existing && existing.spanId && existing.spanId !== stamped.spanId) {
654
+ throw new DispatchDuplicateError({
655
+ existingSpanId: existing.spanId,
656
+ existingStatus: existing.status,
657
+ newSpanId: stamped.spanId,
658
+ pair: { stage: stamped.stage, agent: stamped.agent }
659
+ });
660
+ }
661
+ }
448
662
  await appendDelegationEvent(projectRoot, eventFromEntry(stamped));
449
663
  const ledger = {
450
664
  runId: activeRunId,
@@ -14,6 +14,7 @@ import { parseAdvanceStageArgs, parseCancelRunArgs, parseHookArgs, parseRewindAr
14
14
  import { parseFlowStateRepairArgs, runFlowStateRepair } from "./flow-state-repair.js";
15
15
  import { parseWaiverGrantArgs, runWaiverGrant } from "./waiver-grant.js";
16
16
  import { FlowStateGuardMismatchError, verifyFlowStateGuard } from "../run-persistence.js";
17
+ import { DelegationTimestampError, DispatchDuplicateError } from "../delegation.js";
17
18
  /**
18
19
  * Subcommands that mutate or consult flow-state.json via the CLI runtime.
19
20
  * They all require the sha256 sidecar to match before continuing so a
@@ -91,6 +92,14 @@ export async function runInternalCommand(projectRoot, argv, io) {
91
92
  io.stderr.write(`cclaw internal ${subcommand}: ${err.message}\n`);
92
93
  return 2;
93
94
  }
95
+ if (err instanceof DelegationTimestampError) {
96
+ io.stderr.write(`error: delegation_timestamp_non_monotonic — ${err.field}: ${err.actual} < ${err.priorBound}\n`);
97
+ return 2;
98
+ }
99
+ if (err instanceof DispatchDuplicateError) {
100
+ io.stderr.write(`error: dispatch_duplicate — ${err.message}\n`);
101
+ return 2;
102
+ }
94
103
  io.stderr.write(`cclaw internal ${subcommand} failed: ${err instanceof Error ? err.message : String(err)}\n`);
95
104
  return 1;
96
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "6.7.0",
3
+ "version": "6.8.0",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {