@ngockhoale/ukit 3.4.67 → 3.4.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/manifests/documentation.yaml +33 -0
- package/manifests/instructionRules.yaml +10 -0
- package/manifests/platform.full.yaml +67 -0
- package/package.json +1 -1
- package/src/core/applyPlan.js +111 -2
- package/src/core/buildPlan.js +3 -1
- package/src/core/diffPlan.js +49 -1
- package/src/core/executionContracts.js +49 -8
- package/src/core/metadata.js +45 -0
- package/src/core/runInstallPipeline.js +30 -2
- package/src/core/uninstall.js +106 -2
- package/src/manifest/validateManifest.js +25 -1
- package/template_project/.claude/agents/code-reviewer.md +22 -9
- package/template_project/.claude/agents/handoff-planner.md +97 -8
- package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
- package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
- package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
- package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
- package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
- package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
- package/template_project/.claude/hooks/handoff-resume.sh +141 -1
- package/template_project/.claude/hooks/record-execution.mjs +140 -9
- package/template_project/.claude/hooks/skill-router.sh +173 -8
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
- package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
- package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
- package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
- package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
- package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
- package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
- package/template_project/.claude/ukit/index/route-task.mjs +180 -11
- package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
- package/template_project/.claude/ukit/runtime/async-lock.mjs +3 -3
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
- package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
- package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
- package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
- package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
- package/template_project/.codex/README.md +25 -0
- package/template_project/.omp/RULES.md +22 -22
- package/template_project/.omp/agents/code-reviewer.md +22 -9
- package/template_project/.omp/agents/handoff-planner.md +102 -12
- package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
- package/template_project/AGENTS.md +5 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
- package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
- package/template_project/instructions/core.md +5 -1
- package/template_project/instructions/layout.yaml +2 -0
- package/template_project/instructions/overlays/omp-rules.md +22 -22
- package/template_project/ukit/storage/config.json +1 -1
|
@@ -742,6 +742,128 @@ function claimActive(claim, now) {
|
|
|
742
742
|
: false;
|
|
743
743
|
}
|
|
744
744
|
|
|
745
|
+
// Cycle identity (stale-claim across cycles hardening): task ids restart per
|
|
746
|
+
// cycle, so a bare task id names BOTH the archived cycle's task and the live
|
|
747
|
+
// cycle's task. A run pins its cycle with the additive `Cycle:` field in
|
|
748
|
+
// RUN.md (the same identity stop-coordinator's parseRunCycle defines); claims
|
|
749
|
+
// stamped with it let acquire/renew tell a PREVIOUS cycle's ghost from the
|
|
750
|
+
// CURRENT cycle's live worker. Normalized `cycle-NN`; anything unparseable →
|
|
751
|
+
// null (no stamp → the legacy, cycle-blind behavior).
|
|
752
|
+
export function normalizeClaimCycle(value) {
|
|
753
|
+
const raw = String(value ?? '').trim();
|
|
754
|
+
const m = raw.match(/^(?:cycle-)?(\d+)$/i);
|
|
755
|
+
return m ? `cycle-${m[1].padStart(2, '0')}` : null;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// True only when BOTH sides carry a cycle stamp and they differ — the only
|
|
759
|
+
// provably-stale shape. Unstamped (legacy) claims always take the legacy path,
|
|
760
|
+
// so an upgraded install never preempts a worker it cannot reason about.
|
|
761
|
+
function claimCycleDiffers(existing, incoming) {
|
|
762
|
+
return Boolean(existing?.cycle) && Boolean(incoming) && existing.cycle !== incoming;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// True when the live claim's cycle is strictly newer than the incoming (stale) one.
|
|
766
|
+
function claimCycleOlder(existing, incoming) {
|
|
767
|
+
if (!existing?.cycle || !incoming) return false;
|
|
768
|
+
const a = Number(String(existing.cycle).replace(/\D/g, ''));
|
|
769
|
+
const b = Number(String(incoming).replace(/\D/g, ''));
|
|
770
|
+
return Number.isFinite(a) && Number.isFinite(b) && b < a;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// A claim stamped with an OLDER cycle is a ghost (may be superseded) only when that cycle is PROVEN
|
|
774
|
+
// archived for THIS task. Proof is not a path: every component of docs/AI_HANDOFF/<ARCHIVE|archive>/
|
|
775
|
+
// cycle-NN must be a real non-symlink directory, and the archive's INDEX.md snapshot (written at
|
|
776
|
+
// archive time, before INDEX is reset) must be a readable, well-formed, non-symlink file with a
|
|
777
|
+
// terminal row for the claimed task — `done`, or `cancelled_superseded` resolved by a terminal
|
|
778
|
+
// `<root>-R<n>` successor in the same snapshot. Empty dir, regular file, symlink, malformed,
|
|
779
|
+
// unreadable, missing row or nonterminal row all deny (false). Unknown never supersedes.
|
|
780
|
+
const SAFE_DIR_ERRNO = new Set(['ENOENT']);
|
|
781
|
+
|
|
782
|
+
async function realDirectoryOrAbsent(target) {
|
|
783
|
+
try {
|
|
784
|
+
const st = await fs.lstat(target);
|
|
785
|
+
return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'unsafe';
|
|
786
|
+
} catch (error) {
|
|
787
|
+
return SAFE_DIR_ERRNO.has(error?.code) ? 'absent' : 'unsafe';
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function parseArchiveSnapshotStatuses(text) {
|
|
792
|
+
const map = new Map();
|
|
793
|
+
for (const line of String(text).split('\n')) {
|
|
794
|
+
const row = line.trim();
|
|
795
|
+
if (!/^\|\s*TASK-/i.test(row)) continue;
|
|
796
|
+
const cells = row.split('|').slice(1, -1).map((c) => c.trim());
|
|
797
|
+
if (!row.startsWith('|') || !row.endsWith('|') || cells.length !== 8) return null;
|
|
798
|
+
const key = cells[0].toLowerCase();
|
|
799
|
+
if (map.has(key)) return null;
|
|
800
|
+
map.set(key, cells[4].toLowerCase());
|
|
801
|
+
}
|
|
802
|
+
return map;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Terminal-success under the snapshot: done, or grounded cancellation resolved by a terminal successor.
|
|
806
|
+
function snapshotTaskTerminal(key, statuses, seen = new Set()) {
|
|
807
|
+
if (seen.has(key)) return false;
|
|
808
|
+
seen.add(key);
|
|
809
|
+
const st = statuses.get(key);
|
|
810
|
+
if (st === 'done') return true;
|
|
811
|
+
if (st !== 'cancelled_superseded') return false;
|
|
812
|
+
const root = key.replace(/-r\d+$/, '');
|
|
813
|
+
const currentRepair = BigInt(key.match(/-r(\d+)$/)?.[1] ?? '0');
|
|
814
|
+
for (const [other, ost] of statuses) {
|
|
815
|
+
if (!other.startsWith(`${root}-r`) || !/^\d+$/.test(other.slice(root.length + 2))) continue;
|
|
816
|
+
if (BigInt(other.slice(root.length + 2)) <= currentRepair) continue;
|
|
817
|
+
if (ost === 'done' || (ost === 'cancelled_superseded' && snapshotTaskTerminal(other, statuses, seen))) return true;
|
|
818
|
+
}
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function claimCycleFinalized(projectRoot, cycle, taskId) {
|
|
823
|
+
if (!cycle || !/^cycle-\d+$/.test(cycle) || !taskId) return false;
|
|
824
|
+
const ai = path.join(projectRoot, 'docs', 'AI_HANDOFF');
|
|
825
|
+
for (const parent of [path.join(projectRoot, 'docs'), ai]) {
|
|
826
|
+
if ((await realDirectoryOrAbsent(parent)) !== 'dir') return false;
|
|
827
|
+
}
|
|
828
|
+
let proven = false;
|
|
829
|
+
for (const name of ['ARCHIVE', 'archive']) {
|
|
830
|
+
const base = path.join(ai, name);
|
|
831
|
+
const baseState = await realDirectoryOrAbsent(base);
|
|
832
|
+
if (baseState === 'unsafe') return false;
|
|
833
|
+
if (baseState === 'absent') continue;
|
|
834
|
+
const cycleDir = path.join(base, cycle);
|
|
835
|
+
const cycleState = await realDirectoryOrAbsent(cycleDir);
|
|
836
|
+
if (cycleState === 'unsafe') return false;
|
|
837
|
+
if (cycleState === 'absent') continue;
|
|
838
|
+
// Snapshot must be a regular, non-symlink file that is readable and well-formed.
|
|
839
|
+
let raw;
|
|
840
|
+
try {
|
|
841
|
+
const st = await fs.lstat(path.join(cycleDir, 'INDEX.md'));
|
|
842
|
+
if (!st.isFile() || st.isSymbolicLink()) return false;
|
|
843
|
+
raw = await fs.readFile(path.join(cycleDir, 'INDEX.md'), 'utf8');
|
|
844
|
+
} catch {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
const statuses = parseArchiveSnapshotStatuses(raw);
|
|
848
|
+
if (statuses === null) return false;
|
|
849
|
+
if (snapshotTaskTerminal(goalKey(taskId), statuses)) proven = true;
|
|
850
|
+
else return false;
|
|
851
|
+
}
|
|
852
|
+
return proven;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function goalKey(taskId) {
|
|
856
|
+
return String(taskId ?? '').trim().toLowerCase();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// True when the incoming cycle is strictly newer than the live claim's stamped cycle.
|
|
860
|
+
function claimCycleNewer(incoming, existing) {
|
|
861
|
+
if (!existing?.cycle || !incoming) return false;
|
|
862
|
+
const a = Number(String(existing.cycle).replace(/\D/g, ''));
|
|
863
|
+
const b = Number(String(incoming).replace(/\D/g, ''));
|
|
864
|
+
return Number.isFinite(a) && Number.isFinite(b) && b > a;
|
|
865
|
+
}
|
|
866
|
+
|
|
745
867
|
// Read the claims map. Missing file → empty map; corrupt file → `ok:false`
|
|
746
868
|
// (callers fail closed on mutations, report honestly on reads).
|
|
747
869
|
async function readClaimsMap(projectRoot) {
|
|
@@ -758,13 +880,44 @@ async function readClaimsMap(projectRoot) {
|
|
|
758
880
|
async function writeClaimsMap(projectRoot, claims) {
|
|
759
881
|
const target = taskClaimsPath(projectRoot);
|
|
760
882
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
761
|
-
|
|
883
|
+
let delegations = {};
|
|
884
|
+
try {
|
|
885
|
+
const stored = JSON.parse(await fs.readFile(target, 'utf8'));
|
|
886
|
+
if (stored.delegations !== undefined && !isPlainObject(stored.delegations)) throw new Error('invalid delegations');
|
|
887
|
+
delegations = stored.delegations || {};
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
890
|
+
}
|
|
891
|
+
let delegationCount = Object.keys(delegations).length;
|
|
892
|
+
for (const [taskId, claim] of Object.entries(claims)) {
|
|
893
|
+
if (!claim.grantId || !String(claim.owner).startsWith('deleg:')) continue;
|
|
894
|
+
const key = `${claim.owner}/${taskId}`;
|
|
895
|
+
// Immutable launch tuple: a reused owner must never retarget an old completion.
|
|
896
|
+
// Never evict an original identity: a delayed completion could otherwise retarget a
|
|
897
|
+
// reused tool id. Real dispatch refuses saturation; low-level callers retain lease-only metadata.
|
|
898
|
+
if (!delegations[key] && delegationCount < 4096) {
|
|
899
|
+
delegations[key] = { taskId, owner: claim.owner, cycle: claim.cycle ?? null,
|
|
900
|
+
generation: claim.generation, grantId: claim.grantId, sessionId: claim.sessionId ?? null };
|
|
901
|
+
delegationCount++;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
await writeJsonAtomic(target, { schemaVersion: TASK_CLAIMS_SCHEMA_VERSION, claims, delegations });
|
|
905
|
+
return delegations;
|
|
762
906
|
}
|
|
763
907
|
|
|
764
908
|
/**
|
|
765
909
|
* Claim exclusive execution of `taskId`. Idempotent for the SAME owner (the
|
|
766
910
|
* heartbeat is refreshed); a second owner inside the TTL gets
|
|
767
911
|
* `{ ok: false, reason: 'active-claim' }`; an expired claim can be taken over.
|
|
912
|
+
* `cycle` (the run's `Cycle:` from RUN.md, normalized `cycle-NN`) stamps the
|
|
913
|
+
* claim. A different owner can retire an ACTIVE older-cycle claim only with
|
|
914
|
+
* valid archived delivery proof for that task; a cycle change alone is not proof.
|
|
915
|
+
* Same-owner newer-cycle grants bump generation; stale older-cycle acquires deny.
|
|
916
|
+
* Same-cycle and unstamped different-owner claims remain protected until expiry.
|
|
917
|
+
* `requireTermination` is mandatory for real dispatch: a delegated grant is never
|
|
918
|
+
* replaced using expiry or archived delivery status. Its immutable launch identity
|
|
919
|
+
* must have registry capacity and must not have been used by a previous grant.
|
|
920
|
+
* Low-level callers leaving this false must enforce their own write/landing fence.
|
|
768
921
|
*
|
|
769
922
|
* @returns {Promise<{ok:true, claim:{owner,heartbeat,ttlMs}, refreshed?:boolean, tookOver?:boolean}
|
|
770
923
|
* | {ok:false, reason:string, claim?:object}>}
|
|
@@ -772,12 +925,15 @@ async function writeClaimsMap(projectRoot, claims) {
|
|
|
772
925
|
export async function acquireTaskClaim(projectRoot, taskId, {
|
|
773
926
|
owner = null,
|
|
774
927
|
sessionId = null,
|
|
928
|
+
cycle = null,
|
|
775
929
|
ttlMs = DEFAULT_TASK_CLAIM_TTL_MS,
|
|
776
930
|
now = Date.now(),
|
|
777
931
|
deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
|
|
932
|
+
requireTermination = false,
|
|
778
933
|
} = {}) {
|
|
779
934
|
const key = safeTaskId(taskId);
|
|
780
935
|
const ownerToken = normalizeClaimOwner(owner);
|
|
936
|
+
const claimCycle = normalizeClaimCycle(cycle);
|
|
781
937
|
const ttl = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : DEFAULT_TASK_CLAIM_TTL_MS;
|
|
782
938
|
try {
|
|
783
939
|
const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
|
|
@@ -785,13 +941,64 @@ export async function acquireTaskClaim(projectRoot, taskId, {
|
|
|
785
941
|
if (!read.ok) return { ok: false, reason: read.reason };
|
|
786
942
|
const claims = read.claims;
|
|
787
943
|
const existing = claims[key];
|
|
944
|
+
// Expiry revokes lease authority, not the worker's filesystem access. Real dispatch
|
|
945
|
+
// must not start a competing writer until a terminal handback removed this grant.
|
|
946
|
+
// Keep the low-level lease API compatible for callers that enforce their own fence.
|
|
947
|
+
if (requireTermination && isPlainObject(existing)
|
|
948
|
+
&& String(existing.owner).startsWith('deleg:')
|
|
949
|
+
&& (!claimActive(existing, now)
|
|
950
|
+
|| (existing.owner === ownerToken && claimCycleNewer(claimCycle, existing)))) {
|
|
951
|
+
return { ok: false, reason: 'termination-unconfirmed', claim: { ...existing } };
|
|
952
|
+
}
|
|
788
953
|
if (isPlainObject(existing) && claimActive(existing, now)) {
|
|
789
|
-
if (existing.owner
|
|
790
|
-
|
|
954
|
+
if (existing.owner !== ownerToken) {
|
|
955
|
+
// A different owner's ACTIVE claim is never displaced by a different cycle alone: a
|
|
956
|
+
// freshly acquired live worker has not renewed yet, so "not renewed" does not prove it
|
|
957
|
+
// dead. Its lease expires by TTL. Sole exception: the claim is stamped with an OLDER
|
|
958
|
+
// cycle that has since been archived (completed-only), so that worker is finished and a
|
|
959
|
+
// reused task id must not wait out the TTL. Unknown/unreadable → keep the deny.
|
|
960
|
+
const ghost = !(requireTermination && String(existing.owner).startsWith('deleg:'))
|
|
961
|
+
&& claimCycleNewer(claimCycle, existing)
|
|
962
|
+
&& await claimCycleFinalized(projectRoot, existing.cycle, taskId);
|
|
963
|
+
if (!ghost) return { ok: false, reason: 'active-claim', claim: { ...existing } };
|
|
964
|
+
// ghost: fall through to the takeover below (generation bump fences the old owner)
|
|
965
|
+
} else if (claimCycleOlder(existing, claimCycle)) {
|
|
966
|
+
// Same owner, but the incoming acquire is from an OLDER cycle than the live grant:
|
|
967
|
+
// a late/stale acquire must not regress the newer cycle's claim.
|
|
968
|
+
return { ok: false, reason: 'stale-cycle', claim: { ...existing } };
|
|
969
|
+
} else if (!claimCycleDiffers(existing, claimCycle)) {
|
|
970
|
+
// Same owner, same cycle (or unstamped legacy): refresh the heartbeat in place.
|
|
971
|
+
claims[key] = {
|
|
972
|
+
...existing,
|
|
973
|
+
heartbeat: now,
|
|
974
|
+
ttlMs: existing.ttlMs ?? ttl,
|
|
975
|
+
...(claimCycle ? { cycle: claimCycle } : {}),
|
|
976
|
+
};
|
|
791
977
|
await writeClaimsMap(projectRoot, claims);
|
|
792
978
|
return { ok: true, claim: { ...claims[key] }, refreshed: true };
|
|
793
979
|
}
|
|
794
|
-
|
|
980
|
+
// Same owner, NEWER cycle: a new grant for the new cycle. Falls through to the takeover
|
|
981
|
+
// below, so the generation bumps and the old cycle's token is fenced.
|
|
982
|
+
}
|
|
983
|
+
if (requireTermination && ownerToken.startsWith('deleg:')) {
|
|
984
|
+
// Never launch a worker whose immutable handback tuple cannot be persisted.
|
|
985
|
+
// Do not evict old identities: a delayed notification must not target a new grant.
|
|
986
|
+
let delegations = {};
|
|
987
|
+
try {
|
|
988
|
+
const stored = JSON.parse(await fs.readFile(taskClaimsPath(projectRoot), 'utf8'));
|
|
989
|
+
if (stored.delegations !== undefined && !isPlainObject(stored.delegations)) {
|
|
990
|
+
return { ok: false, reason: 'claims-unreadable' };
|
|
991
|
+
}
|
|
992
|
+
delegations = stored.delegations || {};
|
|
993
|
+
} catch (error) {
|
|
994
|
+
if (error?.code !== 'ENOENT') return { ok: false, reason: 'claims-unreadable' };
|
|
995
|
+
}
|
|
996
|
+
if (delegations[`${ownerToken}/${key}`]) {
|
|
997
|
+
return { ok: false, reason: 'delegation-identity-reused' };
|
|
998
|
+
}
|
|
999
|
+
if (Object.keys(delegations).length >= 4096) {
|
|
1000
|
+
return { ok: false, reason: 'delegation-registry-full' };
|
|
1001
|
+
}
|
|
795
1002
|
}
|
|
796
1003
|
// `generation` is the fencing token: every fresh grant or takeover bumps it, so a result
|
|
797
1004
|
// committed by an owner whose claim lapsed can be told apart from the current holder's.
|
|
@@ -799,15 +1006,20 @@ export async function acquireTaskClaim(projectRoot, taskId, {
|
|
|
799
1006
|
const claim = {
|
|
800
1007
|
owner: ownerToken,
|
|
801
1008
|
heartbeat: now,
|
|
1009
|
+
claimedAt: now,
|
|
1010
|
+
grantId: crypto.randomUUID(),
|
|
802
1011
|
ttlMs: ttl,
|
|
803
1012
|
generation,
|
|
1013
|
+
...(claimCycle ? { cycle: claimCycle } : {}),
|
|
804
1014
|
...(sessionId ? { sessionId: String(sessionId) } : {}),
|
|
805
1015
|
};
|
|
806
1016
|
claims[key] = claim;
|
|
807
|
-
await writeClaimsMap(projectRoot, claims);
|
|
1017
|
+
const delegations = await writeClaimsMap(projectRoot, claims);
|
|
808
1018
|
return {
|
|
809
1019
|
ok: true,
|
|
810
1020
|
claim: { ...claim },
|
|
1021
|
+
...(ownerToken.startsWith('deleg:') && !delegations[`${ownerToken}/${key}`]
|
|
1022
|
+
? { completionRecovery: 'lease-only' } : {}),
|
|
811
1023
|
...(isPlainObject(existing) ? { tookOver: true } : {}),
|
|
812
1024
|
};
|
|
813
1025
|
});
|
|
@@ -823,11 +1035,14 @@ export async function acquireTaskClaim(projectRoot, taskId, {
|
|
|
823
1035
|
/** Refresh the heartbeat of a claim this owner still holds. Never creates one. */
|
|
824
1036
|
export async function heartbeatTaskClaim(projectRoot, taskId, {
|
|
825
1037
|
owner = null,
|
|
1038
|
+
cycle = null,
|
|
1039
|
+
generation = null,
|
|
826
1040
|
now = Date.now(),
|
|
827
1041
|
deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
|
|
828
1042
|
} = {}) {
|
|
829
1043
|
const key = safeTaskId(taskId);
|
|
830
1044
|
const ownerToken = normalizeClaimOwner(owner);
|
|
1045
|
+
const beatCycle = normalizeClaimCycle(cycle);
|
|
831
1046
|
try {
|
|
832
1047
|
const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
|
|
833
1048
|
const read = await readClaimsMap(projectRoot);
|
|
@@ -835,6 +1050,12 @@ export async function heartbeatTaskClaim(projectRoot, taskId, {
|
|
|
835
1050
|
const existing = read.claims[key];
|
|
836
1051
|
if (!isPlainObject(existing)) return { ok: false, reason: 'no-claim' };
|
|
837
1052
|
if (existing.owner !== ownerToken) return { ok: false, reason: 'owner-mismatch' };
|
|
1053
|
+
// Same fences as release: an old-cycle / superseded-generation holder must not keep a
|
|
1054
|
+
// newer grant alive.
|
|
1055
|
+
if (claimCycleDiffers(existing, beatCycle)) return { ok: false, reason: 'cycle-mismatch' };
|
|
1056
|
+
if (generation !== null && Number(existing.generation) !== Number(generation)) {
|
|
1057
|
+
return { ok: false, reason: 'generation-mismatch' };
|
|
1058
|
+
}
|
|
838
1059
|
read.claims[key] = { ...existing, heartbeat: now };
|
|
839
1060
|
await writeClaimsMap(projectRoot, read.claims);
|
|
840
1061
|
return { ok: true, claim: { ...read.claims[key] } };
|
|
@@ -846,13 +1067,47 @@ export async function heartbeatTaskClaim(projectRoot, taskId, {
|
|
|
846
1067
|
}
|
|
847
1068
|
}
|
|
848
1069
|
|
|
1070
|
+
// Notifications name the original tool-use-id, not the worker's task-id.
|
|
1071
|
+
export async function releaseCompletedDelegation(projectRoot, toolUseId, sessionId) {
|
|
1072
|
+
if (!toolUseId || !sessionId) return;
|
|
1073
|
+
let grants;
|
|
1074
|
+
let legacy = {};
|
|
1075
|
+
try {
|
|
1076
|
+
const store = JSON.parse(await fs.readFile(taskClaimsPath(projectRoot), 'utf8'));
|
|
1077
|
+
grants = store.delegations || {};
|
|
1078
|
+
if (isPlainObject(store.claims)) legacy = store.claims;
|
|
1079
|
+
} catch { return; }
|
|
1080
|
+
if (!isPlainObject(grants)) return;
|
|
1081
|
+
// Pre-grant claims (written before grant ids existed) have no registry entry. The same
|
|
1082
|
+
// authenticated handback (original tool-use id + session) still ends them; a claim that
|
|
1083
|
+
// carries a grantId is only ever released through its registered grant above.
|
|
1084
|
+
for (const [taskId, claim] of Object.entries(legacy)) {
|
|
1085
|
+
if (!isPlainObject(claim) || (claim.grantId !== undefined && claim.grantId !== null) || claim.owner !== `deleg:${toolUseId}`
|
|
1086
|
+
|| claim.sessionId !== sessionId || !Number.isInteger(claim.generation)) continue;
|
|
1087
|
+
await releaseTaskClaim(projectRoot, taskId, { owner: claim.owner, cycle: claim.cycle, generation: claim.generation });
|
|
1088
|
+
}
|
|
1089
|
+
for (const grant of Object.values(grants)) {
|
|
1090
|
+
if (!isPlainObject(grant) || typeof grant.owner !== 'string' || !grant.owner.startsWith('deleg:')
|
|
1091
|
+
|| typeof grant.taskId !== 'string' || !grant.taskId.trim()
|
|
1092
|
+
|| !Number.isInteger(grant.generation) || grant.generation < 1
|
|
1093
|
+
|| typeof grant.grantId !== 'string' || !grant.grantId
|
|
1094
|
+
|| grant.owner !== `deleg:${toolUseId}` || grant.sessionId !== sessionId) continue;
|
|
1095
|
+
await releaseTaskClaim(projectRoot, grant.taskId, { owner: grant.owner, cycle: grant.cycle,
|
|
1096
|
+
generation: grant.generation, grantId: grant.grantId });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
849
1100
|
/** Release a claim. Only the owner can remove it; absent claims release clean. */
|
|
850
1101
|
export async function releaseTaskClaim(projectRoot, taskId, {
|
|
851
1102
|
owner = null,
|
|
1103
|
+
cycle = null,
|
|
1104
|
+
generation = null,
|
|
1105
|
+
grantId = null,
|
|
852
1106
|
deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
|
|
853
1107
|
} = {}) {
|
|
854
1108
|
const key = safeTaskId(taskId);
|
|
855
1109
|
const ownerToken = normalizeClaimOwner(owner);
|
|
1110
|
+
const releaseCycle = normalizeClaimCycle(cycle);
|
|
856
1111
|
try {
|
|
857
1112
|
const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
|
|
858
1113
|
const read = await readClaimsMap(projectRoot);
|
|
@@ -860,6 +1115,13 @@ export async function releaseTaskClaim(projectRoot, taskId, {
|
|
|
860
1115
|
const existing = read.claims[key];
|
|
861
1116
|
if (!isPlainObject(existing)) return { ok: true, released: false };
|
|
862
1117
|
if (existing.owner !== ownerToken) return { ok: false, reason: 'owner-mismatch' };
|
|
1118
|
+
// Cycle + generation fences: a late release from an older cycle, or by a holder whose
|
|
1119
|
+
// generation was superseded, must not delete the current claim.
|
|
1120
|
+
if (claimCycleDiffers(existing, releaseCycle)) return { ok: false, reason: 'cycle-mismatch' };
|
|
1121
|
+
if (generation !== null && Number(existing.generation) !== Number(generation)) {
|
|
1122
|
+
return { ok: false, reason: 'generation-mismatch' };
|
|
1123
|
+
}
|
|
1124
|
+
if (grantId !== null && existing.grantId !== grantId) return { ok: false, reason: 'grant-mismatch' };
|
|
863
1125
|
delete read.claims[key];
|
|
864
1126
|
await writeClaimsMap(projectRoot, read.claims);
|
|
865
1127
|
return { ok: true, released: true };
|
|
@@ -891,6 +1153,8 @@ export async function readTaskClaim(projectRoot, taskId, { now = Date.now() } =
|
|
|
891
1153
|
// FIRST `TASK-…` in the delegation text is the primary task (later ids are dependencies).
|
|
892
1154
|
export const CLAIMING_AGENT_TYPES = new Set(['feature-implementer', 'bug-debugger']);
|
|
893
1155
|
export const DELEGATED_CLAIM_TTL_MS = 30 * 60 * 1000;
|
|
1156
|
+
// Parent activity is not proof that a delegated worker is still alive.
|
|
1157
|
+
export const DELEGATED_CLAIM_MAX_RENEWAL_MS = 8 * DELEGATED_CLAIM_TTL_MS;
|
|
894
1158
|
const DELEGATED_TASK_ID_RE = /\bTASK-(?:C\d+-)?\d+\b/gi;
|
|
895
1159
|
// A TASK id directly after an execution verb is the delegation's primary task; ids elsewhere in
|
|
896
1160
|
// the text are dependencies or context ("after TASK-001", "see TASK-004").
|
|
@@ -945,9 +1209,9 @@ export function delegatedClaimTargets(toolInput, { isKnownTask = null } = {}) {
|
|
|
945
1209
|
return targets;
|
|
946
1210
|
}
|
|
947
1211
|
|
|
948
|
-
/** Stable owner token for one delegation
|
|
1212
|
+
/** Stable owner token for one delegation; a session id cannot identify siblings. */
|
|
949
1213
|
export function delegatedClaimOwner(payload) {
|
|
950
|
-
const id = payload?.tool_use_id
|
|
1214
|
+
const id = payload?.tool_use_id;
|
|
951
1215
|
return typeof id === 'string' && id ? `deleg:${id}` : null;
|
|
952
1216
|
}
|
|
953
1217
|
|
|
@@ -956,16 +1220,26 @@ export function delegatedClaimOwner(payload) {
|
|
|
956
1220
|
* through its own session activity. Any tool event of the owning session (the parent's hooks
|
|
957
1221
|
* fire for subagent tool calls too) refreshes that session's active claims, so a LIVE task is
|
|
958
1222
|
* never handed to a second agent by TTL alone; only a session that went silent for a full TTL
|
|
959
|
-
* loses its claim.
|
|
1223
|
+
* loses its claim. `cycle` (the run's current `Cycle:`) scopes the renewal to this run: a
|
|
1224
|
+
* claim stamped with a DIFFERENT cycle is a previous cycle's ghost on a reused task id, and
|
|
1225
|
+
* the session's continued activity must not keep it alive — it expires by its TTL instead.
|
|
1226
|
+
* Unstamped claims renew as before. Lock-free pre-check keeps the common call (nothing due)
|
|
1227
|
+
* free of I/O writes.
|
|
960
1228
|
*/
|
|
961
1229
|
export async function renewSessionClaims(projectRoot, sessionId, {
|
|
1230
|
+
cycle = null,
|
|
962
1231
|
now = Date.now(),
|
|
963
1232
|
deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
|
|
964
1233
|
} = {}) {
|
|
965
1234
|
const sid = typeof sessionId === 'string' ? sessionId : '';
|
|
966
1235
|
if (!sid) return { renewed: 0 };
|
|
1236
|
+
const currentCycle = normalizeClaimCycle(cycle);
|
|
967
1237
|
const due = (claim) => isPlainObject(claim) && claim.sessionId === sid && claimActive(claim, now)
|
|
968
|
-
&&
|
|
1238
|
+
&& (!String(claim.owner || '').startsWith('deleg:')
|
|
1239
|
+
|| now - Number(claim.claimedAt ?? claim.heartbeat) < DELEGATED_CLAIM_MAX_RENEWAL_MS)
|
|
1240
|
+
&& now - Number(claim.heartbeat) > Number(claim.ttlMs) / 10
|
|
1241
|
+
// A provably foreign-cycle claim is never renewed: the ghost ages out.
|
|
1242
|
+
&& !(currentCycle && claim.cycle && claim.cycle !== currentCycle);
|
|
969
1243
|
const peek = await readClaimsMap(projectRoot);
|
|
970
1244
|
if (!peek.ok || !Object.values(peek.claims).some(due)) return { renewed: 0 };
|
|
971
1245
|
try {
|
|
@@ -975,7 +1249,7 @@ export async function renewSessionClaims(projectRoot, sessionId, {
|
|
|
975
1249
|
let renewed = 0;
|
|
976
1250
|
for (const [key, claim] of Object.entries(read.claims)) {
|
|
977
1251
|
if (due(claim)) {
|
|
978
|
-
read.claims[key] = { ...claim, heartbeat: now };
|
|
1252
|
+
read.claims[key] = { ...claim, heartbeat: now, claimedAt: claim.claimedAt ?? claim.heartbeat };
|
|
979
1253
|
renewed += 1;
|
|
980
1254
|
}
|
|
981
1255
|
}
|
|
@@ -993,10 +1267,11 @@ export async function renewSessionClaims(projectRoot, sessionId, {
|
|
|
993
1267
|
* task (and, when given, at the same generation). An owner whose lease lapsed and was taken over
|
|
994
1268
|
* must not commit — the new holder's generation differs.
|
|
995
1269
|
*/
|
|
996
|
-
export async function verifyTaskClaimFence(projectRoot, taskId, { owner, generation = null, now = Date.now() } = {}) {
|
|
1270
|
+
export async function verifyTaskClaimFence(projectRoot, taskId, { owner, generation = null, grantId = null, now = Date.now() } = {}) {
|
|
997
1271
|
const read = await readTaskClaim(projectRoot, taskId, { now });
|
|
998
1272
|
if (!read.claimed || !read.active) return false;
|
|
999
1273
|
if (read.claim.owner !== owner) return false;
|
|
1274
|
+
if (grantId !== null && read.claim.grantId !== grantId) return false;
|
|
1000
1275
|
return generation === null || read.claim.generation === generation;
|
|
1001
1276
|
}
|
|
1002
1277
|
|
|
@@ -1081,11 +1356,15 @@ export async function archiveCycleSnapshot(projectRoot, cycle) {
|
|
|
1081
1356
|
// cycle must never be attributed here: an existing snapshot row is immutable (prior wins);
|
|
1082
1357
|
// a live row only fills an id that has no snapshot row yet.
|
|
1083
1358
|
const ids = new Set();
|
|
1359
|
+
const taskFiles = new Map();
|
|
1084
1360
|
try {
|
|
1085
1361
|
for (const f of await fs.readdir(dir)) {
|
|
1086
1362
|
if (!/^TASK-[\w.-]+\.md$/i.test(f)) continue;
|
|
1087
1363
|
if ((await fs.lstat(path.join(dir, f))).isSymbolicLink()) return { ok: false, reason: 'archive-symlink' };
|
|
1088
|
-
|
|
1364
|
+
const id = f.replace(/\.md$/i, '').toUpperCase();
|
|
1365
|
+
ids.add(id);
|
|
1366
|
+
if (!taskFiles.has(id)) taskFiles.set(id, []);
|
|
1367
|
+
taskFiles.get(id).push(f);
|
|
1089
1368
|
}
|
|
1090
1369
|
} catch (error) {
|
|
1091
1370
|
return { ok: false, reason: error?.code === 'ENOENT' ? 'no-task-files' : 'archive-unreadable' };
|
|
@@ -1114,13 +1393,15 @@ export async function archiveCycleSnapshot(projectRoot, cycle) {
|
|
|
1114
1393
|
let stamped = 0;
|
|
1115
1394
|
for (const [id, row] of merged) {
|
|
1116
1395
|
if (!TERMINAL_TASK_STATUSES.has(row.status)) continue;
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1396
|
+
for (const filename of taskFiles.get(id)) {
|
|
1397
|
+
const file = path.join(dir, filename);
|
|
1398
|
+
let text;
|
|
1399
|
+
try { text = await fs.readFile(file, 'utf8'); } catch { continue; }
|
|
1400
|
+
const m = text.match(/^(-\s*Status:\s*`?)([\w-]+)(`?)/im);
|
|
1401
|
+
if (!m || m[2].toLowerCase() === row.status) continue;
|
|
1402
|
+
await fs.writeFile(file, text.replace(m[0], `${m[1]}${row.status}${m[3]}`), 'utf8');
|
|
1403
|
+
stamped += 1;
|
|
1404
|
+
}
|
|
1124
1405
|
}
|
|
1125
1406
|
return { ok: true, cycle: nn, snapshotted: merged.size, stamped, unchanged: unchanged && stamped === 0 };
|
|
1126
1407
|
}
|
|
@@ -1212,8 +1493,16 @@ async function main() {
|
|
|
1212
1493
|
owner,
|
|
1213
1494
|
sessionId: owner.includes(':') ? owner.slice(owner.indexOf(':') + 1) : null,
|
|
1214
1495
|
ttlMs: DELEGATED_CLAIM_TTL_MS,
|
|
1496
|
+
requireTermination: true,
|
|
1497
|
+
// Optional cycle stamp (`--cycle 2`) so a main-agent claim participates
|
|
1498
|
+
// in the same cross-cycle reconcile the delegation path uses.
|
|
1499
|
+
cycle: typeof flags.cycle === 'string' ? flags.cycle : null,
|
|
1215
1500
|
})
|
|
1216
|
-
: await releaseTaskClaim(projectRoot, taskId.toUpperCase(), {
|
|
1501
|
+
: await releaseTaskClaim(projectRoot, taskId.toUpperCase(), {
|
|
1502
|
+
owner,
|
|
1503
|
+
cycle: typeof flags.cycle === 'string' ? flags.cycle : null,
|
|
1504
|
+
generation: typeof flags.generation === 'string' ? flags.generation : null,
|
|
1505
|
+
});
|
|
1217
1506
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1218
1507
|
if (result.ok === false) process.exitCode = 3;
|
|
1219
1508
|
return;
|
|
@@ -1221,7 +1510,7 @@ async function main() {
|
|
|
1221
1510
|
process.stderr.write(
|
|
1222
1511
|
'usage: resumable-run.mjs read <projectRoot> <taskId> [--boundary <id>]\n'
|
|
1223
1512
|
+ ' resumable-run.mjs archive-cycle <projectRoot> <NN>\n'
|
|
1224
|
-
+ ' resumable-run.mjs claim|release <projectRoot> <taskId> --owner <main:session
|
|
1513
|
+
+ ' resumable-run.mjs claim|release <projectRoot> <taskId> --owner <main:session> [--cycle <NN>] [--generation <N>]\n'
|
|
1225
1514
|
+ ' resumable-run.mjs suspend <projectRoot> <taskId> [--boundary <id>] '
|
|
1226
1515
|
+ '[--phase <phase>] [--next <action>] [--completed <a,b,...>]\n',
|
|
1227
1516
|
);
|