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.
- package/dist/content/hooks.js +205 -6
- package/dist/content/skills.js +2 -0
- package/dist/delegation.d.ts +98 -0
- package/dist/delegation.js +220 -6
- package/dist/internal/advance-stage.js +9 -0
- package/package.json +1 -1
package/dist/content/hooks.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/content/skills.js
CHANGED
|
@@ -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() {
|
package/dist/delegation.d.ts
CHANGED
|
@@ -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.
|
package/dist/delegation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|