@hecer/yoke 1.3.0 → 1.4.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +67 -15
- package/TODOS.md +0 -3
- package/canon/loop/loop-spec.md +22 -8
- package/canon/manifest.yaml +1 -1
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/cli.js +55 -3
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/loop.js +109 -16
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +4 -1
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +204 -51
- package/dist/loop/runner.js +67 -32
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +4 -2
- package/dist/retrofit/gitignore.js +3 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const ClaimOperationLeaseSchema = z.object({
|
|
6
|
+
schemaVersion: z.literal(1),
|
|
7
|
+
token: z.string().uuid(),
|
|
8
|
+
pid: z.number().int().positive(),
|
|
9
|
+
createdAt: z.string().min(1),
|
|
10
|
+
});
|
|
11
|
+
function operationPath(file) {
|
|
12
|
+
return `${file}.operation`;
|
|
13
|
+
}
|
|
14
|
+
function recoveryPath(file) {
|
|
15
|
+
return `${operationPath(file)}.recovery`;
|
|
16
|
+
}
|
|
17
|
+
function errorCode(error) {
|
|
18
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
19
|
+
return undefined;
|
|
20
|
+
return typeof error.code === 'string' ? error.code : undefined;
|
|
21
|
+
}
|
|
22
|
+
function publish(file, value, token = randomUUID()) {
|
|
23
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
24
|
+
const temporary = `${file}.${token}.tmp`;
|
|
25
|
+
writeFileSync(temporary, JSON.stringify(value), { flag: 'wx' });
|
|
26
|
+
try {
|
|
27
|
+
linkSync(temporary, file);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (errorCode(error) === 'EEXIST')
|
|
32
|
+
return false;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
rmSync(temporary, { force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function readLease(file) {
|
|
40
|
+
if (!existsSync(file))
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
return ClaimOperationLeaseSchema.parse(JSON.parse(readFileSync(file, 'utf8')));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function expired(lease, options) {
|
|
50
|
+
const createdAt = Date.parse(lease.createdAt);
|
|
51
|
+
return !options.isAlive(lease.pid) && (!Number.isFinite(createdAt) || options.now.getTime() - createdAt > options.staleMs);
|
|
52
|
+
}
|
|
53
|
+
function lease(token, options) {
|
|
54
|
+
return { schemaVersion: 1, token, pid: process.pid, createdAt: options.now.toISOString() };
|
|
55
|
+
}
|
|
56
|
+
function acquireRecovery(file, options) {
|
|
57
|
+
const token = randomUUID();
|
|
58
|
+
if (publish(file, lease(token, options), token))
|
|
59
|
+
return token;
|
|
60
|
+
const observed = readLease(file);
|
|
61
|
+
if (!observed || !expired(observed, options))
|
|
62
|
+
return null;
|
|
63
|
+
options.filesystem?.beforeRecoveryRemove?.(file);
|
|
64
|
+
const current = readLease(file);
|
|
65
|
+
if (!current || current.token !== observed.token || !expired(current, options))
|
|
66
|
+
return null;
|
|
67
|
+
rmSync(file, { force: true });
|
|
68
|
+
return publish(file, lease(token, options), token) ? token : null;
|
|
69
|
+
}
|
|
70
|
+
export function publishClaimFile(file, value) {
|
|
71
|
+
return publish(file, value);
|
|
72
|
+
}
|
|
73
|
+
export function replaceClaimFile(file, value) {
|
|
74
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
75
|
+
writeFileSync(temporary, JSON.stringify(value), { flag: 'wx' });
|
|
76
|
+
renameSync(temporary, file);
|
|
77
|
+
}
|
|
78
|
+
export function acquireClaimOperation(file, options) {
|
|
79
|
+
const token = randomUUID();
|
|
80
|
+
const candidate = lease(token, options);
|
|
81
|
+
const operation = operationPath(file);
|
|
82
|
+
if (publish(operation, candidate, token))
|
|
83
|
+
return token;
|
|
84
|
+
const observed = readLease(operation);
|
|
85
|
+
if (!observed || !expired(observed, options))
|
|
86
|
+
return null;
|
|
87
|
+
const recovery = recoveryPath(file);
|
|
88
|
+
const recoveryToken = acquireRecovery(recovery, options);
|
|
89
|
+
if (!recoveryToken)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const current = readLease(operation);
|
|
93
|
+
if (!current || !expired(current, options))
|
|
94
|
+
return null;
|
|
95
|
+
rmSync(operation, { force: true });
|
|
96
|
+
return publish(operation, candidate, token) ? token : null;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
releaseClaimOperation(recovery, recoveryToken, true);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function releaseClaimOperation(file, token, rawPath = false) {
|
|
103
|
+
const operation = rawPath ? file : operationPath(file);
|
|
104
|
+
const current = readLease(operation);
|
|
105
|
+
if (current?.token === token)
|
|
106
|
+
rmSync(operation, { force: true });
|
|
107
|
+
}
|
|
108
|
+
export function withClaimOperation(file, context, readRecords, task) {
|
|
109
|
+
return withClaimOperations(file, context, readRecords, () => [], task);
|
|
110
|
+
}
|
|
111
|
+
export function withClaimOperations(file, context, readRecords, claimFiles, task) {
|
|
112
|
+
const token = acquireClaimOperation(file, context);
|
|
113
|
+
if (!token)
|
|
114
|
+
return null;
|
|
115
|
+
const acquired = [];
|
|
116
|
+
const orderedFiles = [...new Set(claimFiles(readRecords()).filter(candidate => candidate !== file))].sort((left, right) => left.localeCompare(right));
|
|
117
|
+
try {
|
|
118
|
+
for (const nextFile of orderedFiles) {
|
|
119
|
+
const nextToken = acquireClaimOperation(nextFile, context);
|
|
120
|
+
if (!nextToken)
|
|
121
|
+
return null;
|
|
122
|
+
acquired.push({ file: nextFile, token: nextToken });
|
|
123
|
+
}
|
|
124
|
+
return task(readRecords());
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
for (const lease of acquired.reverse())
|
|
128
|
+
releaseClaimOperation(lease.file, lease.token);
|
|
129
|
+
releaseClaimOperation(file, token);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/loop/claims.js
CHANGED
|
@@ -1,57 +1,194 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { isPidAlive } from './lock.js';
|
|
5
|
+
import { publishClaimFile, replaceClaimFile, withClaimOperations } from './claim-lease.js';
|
|
6
|
+
import { storyPathSegment } from './prd.js';
|
|
7
|
+
export const CLAIM_SCHEMA_VERSION = 2;
|
|
8
|
+
const LegacyClaimSchema = z.object({
|
|
9
|
+
storyId: z.string().min(1),
|
|
10
|
+
owner: z.string().min(1),
|
|
11
|
+
pid: z.number().int().positive(),
|
|
12
|
+
claimedAt: z.string().min(1),
|
|
13
|
+
});
|
|
14
|
+
const RichClaimSchema = LegacyClaimSchema.extend({
|
|
15
|
+
schemaVersion: z.literal(CLAIM_SCHEMA_VERSION),
|
|
16
|
+
dispatcherId: z.string().min(1),
|
|
17
|
+
ownerToken: z.string().min(1),
|
|
18
|
+
heartbeatAt: z.string().min(1),
|
|
19
|
+
baseCommit: z.string().min(1).optional(),
|
|
20
|
+
worktree: z.string().min(1).optional(),
|
|
21
|
+
provider: z.enum(['claude', 'codex', 'gemini']).optional(),
|
|
22
|
+
model: z.string().min(1).optional(),
|
|
23
|
+
role: z.enum(['implementation', 'quality', 'repair', 'review']).optional(),
|
|
24
|
+
cancellation: z.object({
|
|
25
|
+
requestedAt: z.string().min(1),
|
|
26
|
+
reason: z.string().min(1),
|
|
27
|
+
}).optional(),
|
|
28
|
+
});
|
|
29
|
+
const StoredClaimSchema = z.union([RichClaimSchema, LegacyClaimSchema]);
|
|
30
|
+
function claimsDir(dir) {
|
|
31
|
+
return join(dir, '.yoke', 'claims');
|
|
32
|
+
}
|
|
33
|
+
function pathFor(dir, id) {
|
|
34
|
+
return join(claimsDir(dir), `${storyPathSegment(id)}.json`);
|
|
35
|
+
}
|
|
36
|
+
function claimOwner(claim) {
|
|
37
|
+
return 'ownerToken' in claim ? claim.ownerToken : claim.owner;
|
|
38
|
+
}
|
|
39
|
+
function readClaimFile(file) {
|
|
40
|
+
if (!existsSync(file))
|
|
41
|
+
return null;
|
|
16
42
|
try {
|
|
17
|
-
|
|
18
|
-
writeFileSync(fd, JSON.stringify(claim));
|
|
19
|
-
closeSync(fd);
|
|
20
|
-
return claim;
|
|
43
|
+
return StoredClaimSchema.parse(JSON.parse(readFileSync(file, 'utf8')));
|
|
21
44
|
}
|
|
22
45
|
catch {
|
|
23
46
|
return null;
|
|
24
47
|
}
|
|
25
48
|
}
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
49
|
+
function claimRecords(dir, storyId) {
|
|
50
|
+
const canonicalPath = pathFor(dir, storyId);
|
|
51
|
+
const records = [];
|
|
52
|
+
const canonicalClaim = readClaimFile(canonicalPath);
|
|
53
|
+
if (canonicalClaim?.storyId === storyId)
|
|
54
|
+
records.push({ path: canonicalPath, claim: canonicalClaim });
|
|
55
|
+
if (!existsSync(claimsDir(dir)))
|
|
56
|
+
return records;
|
|
57
|
+
for (const file of readdirSync(claimsDir(dir))) {
|
|
58
|
+
const path = join(claimsDir(dir), file);
|
|
59
|
+
if (path === canonicalPath)
|
|
60
|
+
continue;
|
|
61
|
+
const claim = readClaimFile(path);
|
|
62
|
+
if (claim?.storyId === storyId)
|
|
63
|
+
records.push({ path, claim });
|
|
64
|
+
}
|
|
65
|
+
return records;
|
|
66
|
+
}
|
|
67
|
+
function resolveClaimRecord(dir, storyId) {
|
|
68
|
+
return claimRecords(dir, storyId)[0] ?? null;
|
|
69
|
+
}
|
|
70
|
+
function matchingClaimRecords(records, ownerToken) {
|
|
71
|
+
return records.filter(record => claimOwner(record.claim) === ownerToken);
|
|
72
|
+
}
|
|
73
|
+
export function readClaim(dir, storyId) {
|
|
74
|
+
return resolveClaimRecord(dir, storyId)?.claim ?? null;
|
|
75
|
+
}
|
|
76
|
+
function isExpired(claim, now, staleMs, isAlive) {
|
|
77
|
+
const heartbeatAt = 'heartbeatAt' in claim ? claim.heartbeatAt : claim.claimedAt;
|
|
78
|
+
const heartbeatMs = Date.parse(heartbeatAt);
|
|
79
|
+
return !isAlive(claim.pid) && (!Number.isFinite(heartbeatMs) || now.getTime() - heartbeatMs > staleMs);
|
|
80
|
+
}
|
|
81
|
+
function richClaim(storyId, owner, options) {
|
|
82
|
+
const now = options.now ?? new Date();
|
|
83
|
+
const ownerToken = options.ownerToken ?? owner;
|
|
84
|
+
return {
|
|
85
|
+
schemaVersion: CLAIM_SCHEMA_VERSION,
|
|
86
|
+
storyId,
|
|
87
|
+
owner: ownerToken,
|
|
88
|
+
ownerToken,
|
|
89
|
+
dispatcherId: options.dispatcherId ?? owner,
|
|
90
|
+
pid: options.pid ?? process.pid,
|
|
91
|
+
claimedAt: now.toISOString(),
|
|
92
|
+
heartbeatAt: now.toISOString(),
|
|
93
|
+
...(options.baseCommit ? { baseCommit: options.baseCommit } : {}),
|
|
94
|
+
...(options.worktree ? { worktree: options.worktree } : {}),
|
|
95
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
96
|
+
...(options.model ? { model: options.model } : {}),
|
|
97
|
+
...(options.role ? { role: options.role } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function promoteClaim(claim, now) {
|
|
101
|
+
if ('schemaVersion' in claim)
|
|
102
|
+
return claim;
|
|
103
|
+
return {
|
|
104
|
+
schemaVersion: CLAIM_SCHEMA_VERSION,
|
|
105
|
+
storyId: claim.storyId,
|
|
106
|
+
owner: claim.owner,
|
|
107
|
+
ownerToken: claim.owner,
|
|
108
|
+
dispatcherId: claim.owner,
|
|
109
|
+
pid: claim.pid,
|
|
110
|
+
claimedAt: claim.claimedAt,
|
|
111
|
+
heartbeatAt: now.toISOString(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function acquireClaim(dir, storyId, owner, options = {}) {
|
|
115
|
+
const candidate = richClaim(storyId, owner, options);
|
|
116
|
+
const now = options.now ?? new Date();
|
|
117
|
+
const staleMs = options.staleMs ?? 30 * 60_000;
|
|
118
|
+
const isAlive = options.isAlive ?? isPidAlive;
|
|
119
|
+
const file = resolveClaimRecord(dir, storyId)?.path ?? pathFor(dir, storyId);
|
|
120
|
+
return withClaimOperations(file, { now, staleMs, isAlive }, () => claimRecords(dir, storyId), records => records.map(record => record.path), records => {
|
|
121
|
+
if (records.some(record => !isExpired(record.claim, now, staleMs, isAlive)))
|
|
122
|
+
return null;
|
|
123
|
+
for (const record of records)
|
|
124
|
+
rmSync(record.path, { force: true });
|
|
125
|
+
return publishClaimFile(pathFor(dir, storyId), candidate) ? candidate : null;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
export function heartbeatClaim(dir, storyId, ownerToken, options = {}) {
|
|
129
|
+
const now = options.now ?? new Date();
|
|
130
|
+
const staleMs = options.staleMs ?? 30 * 60_000;
|
|
131
|
+
const isAlive = options.isAlive ?? isPidAlive;
|
|
132
|
+
const file = resolveClaimRecord(dir, storyId)?.path ?? pathFor(dir, storyId);
|
|
133
|
+
return withClaimOperations(file, { now, staleMs, isAlive }, () => claimRecords(dir, storyId), records => records.map(record => record.path), records => {
|
|
134
|
+
const record = records.find(candidate => claimOwner(candidate.claim) === ownerToken);
|
|
135
|
+
if (!record)
|
|
136
|
+
return null;
|
|
137
|
+
const updated = { ...promoteClaim(record.claim, now), heartbeatAt: now.toISOString() };
|
|
138
|
+
for (const matchingRecord of matchingClaimRecords(records, ownerToken))
|
|
139
|
+
replaceClaimFile(matchingRecord.path, updated);
|
|
140
|
+
return updated;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
export function requestClaimCancellation(dir, storyId, ownerToken, reason, options = {}) {
|
|
144
|
+
const now = options.now ?? new Date();
|
|
145
|
+
const staleMs = options.staleMs ?? 30 * 60_000;
|
|
146
|
+
const isAlive = options.isAlive ?? isPidAlive;
|
|
147
|
+
const file = resolveClaimRecord(dir, storyId)?.path ?? pathFor(dir, storyId);
|
|
148
|
+
return withClaimOperations(file, { now, staleMs, isAlive }, () => claimRecords(dir, storyId), records => records.map(record => record.path), records => {
|
|
149
|
+
const record = records.find(candidate => claimOwner(candidate.claim) === ownerToken);
|
|
150
|
+
if (!record)
|
|
151
|
+
return null;
|
|
152
|
+
const updated = {
|
|
153
|
+
...promoteClaim(record.claim, now),
|
|
154
|
+
cancellation: { reason, requestedAt: now.toISOString() },
|
|
155
|
+
};
|
|
156
|
+
for (const matchingRecord of matchingClaimRecords(records, ownerToken))
|
|
157
|
+
replaceClaimFile(matchingRecord.path, updated);
|
|
158
|
+
return updated;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
export function releaseClaim(dir, storyId, ownerToken, options = {}) {
|
|
162
|
+
const now = options.now ?? new Date();
|
|
163
|
+
const staleMs = options.staleMs ?? 30 * 60_000;
|
|
164
|
+
const isAlive = options.isAlive ?? isPidAlive;
|
|
165
|
+
const file = resolveClaimRecord(dir, storyId)?.path ?? pathFor(dir, storyId);
|
|
166
|
+
return withClaimOperations(file, { now, staleMs, isAlive }, () => claimRecords(dir, storyId), records => records.map(record => record.path), records => {
|
|
167
|
+
const matchingRecords = matchingClaimRecords(records, ownerToken);
|
|
168
|
+
if (!matchingRecords.length)
|
|
31
169
|
return false;
|
|
32
|
-
|
|
170
|
+
for (const record of matchingRecords)
|
|
171
|
+
rmSync(record.path, { force: true });
|
|
33
172
|
return true;
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
173
|
+
}) ?? false;
|
|
38
174
|
}
|
|
39
|
-
export function cleanupClaims(dir,
|
|
40
|
-
const
|
|
175
|
+
export function cleanupClaims(dir, ownerToken) {
|
|
176
|
+
const dirPath = claimsDir(dir);
|
|
41
177
|
let removed = 0;
|
|
42
178
|
try {
|
|
43
|
-
for (const file of readdirSync(
|
|
44
|
-
const path = join(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
179
|
+
for (const file of readdirSync(dirPath)) {
|
|
180
|
+
const path = join(dirPath, file);
|
|
181
|
+
const claim = readClaimFile(path);
|
|
182
|
+
if (!claim)
|
|
183
|
+
continue;
|
|
184
|
+
if (!ownerToken || claimOwner(claim) === ownerToken) {
|
|
185
|
+
rmSync(path, { force: true });
|
|
186
|
+
removed += 1;
|
|
51
187
|
}
|
|
52
|
-
catch { /* scoped cleanup never guesses ownership */ }
|
|
53
188
|
}
|
|
54
189
|
}
|
|
55
|
-
catch {
|
|
190
|
+
catch {
|
|
191
|
+
return removed;
|
|
192
|
+
}
|
|
56
193
|
return removed;
|
|
57
194
|
}
|
package/dist/loop/cleanup.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { parseProviderProcessRecord } from '../agents/process-record.js';
|
|
5
|
+
import { processIncarnation } from '../agents/process-incarnation.js';
|
|
4
6
|
import { acquireTakeoverLease, acquireTakeoverRecoveryLease, lockPath, takeoverLockPath, readLock, isPidAlive, releaseTakeoverLease, releaseTakeoverRecoveryLease, takeoverRecoveryPath, } from './lock.js';
|
|
5
|
-
import {
|
|
7
|
+
import { killProcessForCleanup, killProcessTreeForCleanup } from './watchdog.js';
|
|
8
|
+
import { cleanupClaims } from './claims.js';
|
|
6
9
|
// Reap orphaned runners PROJECT-SCOPED: kill only pids recorded in this project's
|
|
7
10
|
// .yoke/runner.pid files (main dir + each worktree). Never by process-name or
|
|
8
11
|
// command-line pattern — that takes down runners belonging to OTHER projects,
|
|
9
12
|
// which then stall mid-story. Skipped entirely while the loop lock holder is
|
|
10
13
|
// alive: a live loop's runner is healthy, not an orphan.
|
|
11
|
-
function reapRecordedRunners(targetDir, wtDir, isAlive,
|
|
14
|
+
export function reapRecordedRunners(targetDir, wtDir, isAlive, isTreeAlive, killChildTree, killWatchdog, currentIncarnation) {
|
|
12
15
|
const pidFiles = [join(targetDir, '.yoke', 'runner.pid')];
|
|
13
16
|
if (existsSync(wtDir)) {
|
|
14
17
|
for (const name of readdirSync(wtDir))
|
|
@@ -18,28 +21,85 @@ function reapRecordedRunners(targetDir, wtDir, isAlive, killTree) {
|
|
|
18
21
|
for (const file of pidFiles) {
|
|
19
22
|
if (!existsSync(file))
|
|
20
23
|
continue;
|
|
24
|
+
let rec;
|
|
21
25
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
rec = JSON.parse(readFileSync(file, 'utf8'));
|
|
27
|
+
}
|
|
28
|
+
catch { /* malformed record — still consume the file below */ }
|
|
29
|
+
if (!rec) {
|
|
30
|
+
rmSync(file, { force: true });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// Child (agent tree) first, then the watchdog wrapper.
|
|
34
|
+
for (const [pid, incarnation, treeOwned] of [[rec.childPid, rec.childIncarnation, true], [rec.watchdogPid, rec.watchdogIncarnation, false]]) {
|
|
35
|
+
const alive = typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && (isAlive(pid) || (treeOwned && isTreeAlive(pid)));
|
|
36
|
+
if (alive && typeof pid === 'number') {
|
|
37
|
+
if (isAlive(pid) && (typeof incarnation !== 'string' || currentIncarnation(pid) !== incarnation)) {
|
|
38
|
+
throw new Error(`could not confirm legacy runner process incarnation for pid ${pid}`);
|
|
39
|
+
}
|
|
40
|
+
const terminated = treeOwned ? killChildTree(pid) : killWatchdog(pid);
|
|
41
|
+
if (!terminated && (isAlive(pid) || (treeOwned && isTreeAlive(pid)))) {
|
|
42
|
+
throw new Error(`could not confirm legacy runner process tree termination for pid ${pid}`);
|
|
28
43
|
}
|
|
44
|
+
if (terminated)
|
|
45
|
+
killed++;
|
|
29
46
|
}
|
|
30
47
|
}
|
|
31
|
-
catch { /* malformed record — still consume the file below */ }
|
|
32
48
|
rmSync(file, { force: true });
|
|
33
49
|
}
|
|
34
50
|
return killed;
|
|
35
51
|
}
|
|
52
|
+
export function reapProviderProcesses(targetDir, isAlive, isTreeAlive, killTree, currentIncarnation = processIncarnation) {
|
|
53
|
+
const recordsDir = join(targetDir, '.yoke', 'provider-processes');
|
|
54
|
+
if (!existsSync(recordsDir))
|
|
55
|
+
return 0;
|
|
56
|
+
const expectedTarget = resolve(targetDir);
|
|
57
|
+
let killed = 0;
|
|
58
|
+
for (const name of readdirSync(recordsDir)) {
|
|
59
|
+
const recordPath = join(recordsDir, name);
|
|
60
|
+
let parsed = null;
|
|
61
|
+
try {
|
|
62
|
+
parsed = parseProviderProcessRecord(JSON.parse(readFileSync(recordPath, 'utf8')));
|
|
63
|
+
}
|
|
64
|
+
catch { /* malformed records are not trusted or consumed */ }
|
|
65
|
+
if (!parsed || parsed.targetDir !== expectedTarget)
|
|
66
|
+
continue;
|
|
67
|
+
if (isAlive(parsed.childPid) || isTreeAlive(parsed.childPid)) {
|
|
68
|
+
if (isAlive(parsed.childPid) && currentIncarnation(parsed.childPid) !== parsed.startedAt) {
|
|
69
|
+
throw new Error(`could not confirm provider process incarnation for pid ${parsed.childPid}`);
|
|
70
|
+
}
|
|
71
|
+
const terminated = killTree(parsed.childPid);
|
|
72
|
+
if (!terminated && (isAlive(parsed.childPid) || isTreeAlive(parsed.childPid))) {
|
|
73
|
+
throw new Error(`could not confirm provider process tree termination for pid ${parsed.childPid}`);
|
|
74
|
+
}
|
|
75
|
+
if (terminated)
|
|
76
|
+
killed++;
|
|
77
|
+
}
|
|
78
|
+
rmSync(recordPath, { force: true });
|
|
79
|
+
}
|
|
80
|
+
return killed;
|
|
81
|
+
}
|
|
82
|
+
export function isProviderTreeAlive(pid) {
|
|
83
|
+
if (process.platform === 'win32')
|
|
84
|
+
return isPidAlive(pid);
|
|
85
|
+
try {
|
|
86
|
+
process.kill(-pid, 0);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
36
93
|
// Cleans ONLY yoke-created runtime artifacts: recorded orphan runners,
|
|
37
94
|
// .yoke/worktrees/*, and a stale loop.lock. Never touches user-created
|
|
38
95
|
// worktrees, a lock whose holder is alive, or any process it did not record.
|
|
39
96
|
export function runLoopCleanup(targetDir, opts = {}) {
|
|
40
97
|
const git = opts.git ?? ((args, cwd) => { execFileSync('git', args, { cwd, stdio: 'pipe' }); });
|
|
41
98
|
const isAlive = opts.isAlive ?? isPidAlive;
|
|
42
|
-
const
|
|
99
|
+
const killWatchdog = opts.killTree ?? killProcessForCleanup;
|
|
100
|
+
const killProviderTree = opts.killTree ?? killProcessTreeForCleanup;
|
|
101
|
+
const providerTreeAlive = process.platform === 'win32' ? isAlive : isProviderTreeAlive;
|
|
102
|
+
const currentIncarnation = opts.processIncarnation ?? processIncarnation;
|
|
43
103
|
const wtDir = join(targetDir, '.yoke', 'worktrees');
|
|
44
104
|
const recoveryFile = takeoverRecoveryPath(targetDir);
|
|
45
105
|
if (opts.discardStaleRecovery && existsSync(recoveryFile)) {
|
|
@@ -112,20 +172,34 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
112
172
|
console.log(`Loop lock held by a live process (pid ${holder.pid}) — no cleanup performed.`);
|
|
113
173
|
return 0;
|
|
114
174
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
175
|
+
let killed;
|
|
176
|
+
try {
|
|
177
|
+
killed = reapRecordedRunners(targetDir, wtDir, isAlive, providerTreeAlive, killProviderTree, killWatchdog, currentIncarnation) + reapProviderProcesses(targetDir, isAlive, providerTreeAlive, killProviderTree, currentIncarnation);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
console.error(`Failed to reap recorded process trees: ${error.message}`);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
118
183
|
let removed = 0;
|
|
119
184
|
let failed = 0;
|
|
120
185
|
if (existsSync(wtDir)) {
|
|
121
186
|
for (const name of readdirSync(wtDir)) {
|
|
122
187
|
const path = join(wtDir, name);
|
|
188
|
+
try {
|
|
189
|
+
killed += reapProviderProcesses(path, isAlive, providerTreeAlive, killProviderTree, currentIncarnation);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
console.error(`Failed to reap provider processes for worktree ${path}: ${error.message}`);
|
|
193
|
+
failed++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
123
196
|
if (!opts.removeWorktrees) {
|
|
124
197
|
console.log(`Yoke worktree retained: ${path} (pass --remove-worktrees to remove it)`);
|
|
125
198
|
continue;
|
|
126
199
|
}
|
|
127
200
|
try {
|
|
128
201
|
git(['worktree', 'remove', '--force', path], targetDir);
|
|
202
|
+
markCandidateWorktreeRemoved(targetDir, path);
|
|
129
203
|
removed++;
|
|
130
204
|
}
|
|
131
205
|
catch (e) {
|
|
@@ -140,6 +214,10 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
140
214
|
catch { /* best-effort */ }
|
|
141
215
|
}
|
|
142
216
|
}
|
|
217
|
+
if (killed > 0)
|
|
218
|
+
console.log(`Killed ${killed} orphaned process tree(s) from project-scoped Yoke records.`);
|
|
219
|
+
if (opts.removeWorktrees && failed === 0)
|
|
220
|
+
cleanupClaims(targetDir);
|
|
143
221
|
const lockFile = lockPath(targetDir);
|
|
144
222
|
if (existsSync(lockFile)) {
|
|
145
223
|
rmSync(lockFile, { force: true });
|
|
@@ -152,3 +230,27 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
152
230
|
releaseTakeoverLease(targetDir, cleanupLease.ownerToken);
|
|
153
231
|
}
|
|
154
232
|
}
|
|
233
|
+
function markCandidateWorktreeRemoved(targetDir, worktree) {
|
|
234
|
+
const proofRoot = join(targetDir, '.yoke', 'proof');
|
|
235
|
+
if (!existsSync(proofRoot))
|
|
236
|
+
return;
|
|
237
|
+
for (const storyId of readdirSync(proofRoot)) {
|
|
238
|
+
const candidates = join(proofRoot, storyId, 'candidates');
|
|
239
|
+
if (!existsSync(candidates))
|
|
240
|
+
continue;
|
|
241
|
+
for (const candidateId of readdirSync(candidates)) {
|
|
242
|
+
const status = join(candidates, candidateId, 'status.json');
|
|
243
|
+
if (!existsSync(status))
|
|
244
|
+
continue;
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(readFileSync(status, 'utf8'));
|
|
247
|
+
if (parsed.worktree !== worktree)
|
|
248
|
+
continue;
|
|
249
|
+
writeFileSync(status, JSON.stringify({ ...parsed, state: 'removed', reason: 'external cleanup recovered candidate worktree' }));
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
package/dist/loop/decision.js
CHANGED
|
@@ -72,12 +72,43 @@ export const DecisionResumeSchema = z.object({
|
|
|
72
72
|
permissions: z.enum(['safe', 'unsafe', 'read-only']).optional(),
|
|
73
73
|
parallel: z.number().int().positive().optional(),
|
|
74
74
|
routing: z.boolean().optional(),
|
|
75
|
+
quality: z.boolean().optional(),
|
|
76
|
+
qualityRounds: z.number().int().positive().optional(),
|
|
77
|
+
qualityMinutes: z.number().int().positive().optional(),
|
|
78
|
+
qualityPolicy: z.enum(['blocking', 'advisory']).optional(),
|
|
79
|
+
candidates: z.number().int().positive().optional(),
|
|
75
80
|
projectId: z.string().regex(/^[a-f0-9]{16}$/u).optional(),
|
|
76
81
|
baseCommit: z.string().regex(/^[a-f0-9]{40,64}$/u).optional(),
|
|
77
82
|
prdHash: z.string().regex(/^[a-f0-9]{64}$/u).optional(),
|
|
78
83
|
decisionCommit: z.string().regex(/^[a-f0-9]{40,64}$/u).optional(),
|
|
79
84
|
answerId: z.string().regex(/^[a-f0-9]{16}$/u).optional(),
|
|
80
85
|
});
|
|
86
|
+
export function buildTrustedDecisionResumeState(input) {
|
|
87
|
+
return DecisionResumeSchema.parse({
|
|
88
|
+
version: 1,
|
|
89
|
+
storyId: input.storyId,
|
|
90
|
+
requestId: input.requestId,
|
|
91
|
+
answered: false,
|
|
92
|
+
...(input.maxIterations !== undefined ? { maxIterations: input.maxIterations } : {}),
|
|
93
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
94
|
+
isolate: input.isolate,
|
|
95
|
+
...(input.reviewer ? { reviewer: input.reviewer } : {}),
|
|
96
|
+
review: input.review,
|
|
97
|
+
allowSelfReview: input.allowSelfReview,
|
|
98
|
+
...(input.timeoutMinutes !== undefined ? { timeoutMinutes: input.timeoutMinutes } : {}),
|
|
99
|
+
json: input.json,
|
|
100
|
+
...(input.onAmbiguity ? { onAmbiguity: input.onAmbiguity } : {}),
|
|
101
|
+
...(input.decisionPolicy ? { decisionPolicy: input.decisionPolicy } : {}),
|
|
102
|
+
permissions: input.permissions,
|
|
103
|
+
parallel: input.parallel,
|
|
104
|
+
routing: input.routing,
|
|
105
|
+
...(input.quality !== undefined ? { quality: input.quality } : {}),
|
|
106
|
+
...(input.qualityRounds !== undefined ? { qualityRounds: input.qualityRounds } : {}),
|
|
107
|
+
...(input.qualityMinutes !== undefined ? { qualityMinutes: input.qualityMinutes } : {}),
|
|
108
|
+
...(input.qualityPolicy ? { qualityPolicy: input.qualityPolicy } : {}),
|
|
109
|
+
...(input.candidates !== undefined ? { candidates: input.candidates } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
81
112
|
export function decisionRequestPath(targetDir) {
|
|
82
113
|
return join(targetDir, '.yoke', 'decision-request.yaml');
|
|
83
114
|
}
|