taskforce-loop-engineering 0.15.4 → 0.15.5
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
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.5 - 2026-08-24
|
|
6
|
+
|
|
7
|
+
- Treat embedded-runtime `incomplete_turn` / abandoned-liveness trailers as recoverable interruptions even when the dispatcher wrapper exits zero, preventing partial work from being accepted or stale human gates from being replayed.
|
|
8
|
+
- Keep future paid, deployment, and publication authorization boundaries deferred while an unrelated safe project backlog item remains actionable.
|
|
9
|
+
- Reconcile an already-materialized premature deferred gate by superseding it and restoring the project carrier to the runnable queue.
|
|
10
|
+
- Resolve the authoritative project from checkpoint milestones when a task still carries a broader parent project id.
|
|
11
|
+
|
|
5
12
|
## 0.15.4 - 2026-08-22
|
|
6
13
|
|
|
7
14
|
- Preserve source conversation and project metadata across revision planning, direct revision enqueue, and saved-plan application.
|
package/lib/core.mjs
CHANGED
|
@@ -1266,17 +1266,32 @@ export async function reconcileProjectGates(root, options = {}) {
|
|
|
1266
1266
|
gate = { ...gate, ...await gateProjectMetadata(root, task, checkpoint) };
|
|
1267
1267
|
await writeJson(full, gate);
|
|
1268
1268
|
}
|
|
1269
|
-
const spec = specs.
|
|
1269
|
+
const spec = await projectSpecForCheckpoint(root, [...specs.values()], gate.project_id, checkpoint);
|
|
1270
|
+
if (spec?.project && gate.project_id !== spec.project) {
|
|
1271
|
+
gate = { ...gate, project_id: spec.project };
|
|
1272
|
+
await writeJson(full, gate);
|
|
1273
|
+
}
|
|
1274
|
+
const safeBacklogActionable = gate.gate_kind === 'deferred_authorization'
|
|
1275
|
+
&& await projectHasSafeActionableBacklog(root, spec);
|
|
1270
1276
|
const invalid = gate.gate_kind === 'deferred_authorization' && await projectTerminalAccepted(root, spec)
|
|
1271
1277
|
? { code: 'project_accepted_optional_deferred', status: 'superseded' }
|
|
1272
|
-
:
|
|
1278
|
+
: safeBacklogActionable
|
|
1279
|
+
? { code: 'safe_backlog_actionable', status: 'superseded' }
|
|
1280
|
+
: gateInvalidity(gate, spec);
|
|
1273
1281
|
if (!invalid) continue;
|
|
1274
1282
|
const now = new Date().toISOString();
|
|
1275
1283
|
const reconciled = { ...gate, status: invalid.status, reconciliation: { ...invalid, reconciled_at: now } };
|
|
1276
1284
|
await writeJson(full, reconciled);
|
|
1277
1285
|
if (found?.subdir === 'waiting' && task?.waitingGateId === gate.gate_id) {
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1286
|
+
const resumeSafeBacklog = invalid.code === 'safe_backlog_actionable';
|
|
1287
|
+
const destinationFile = path.join(queueSubdirFor(root, queue, resumeSafeBacklog ? 'inbox' : 'canceled'), path.basename(found.file));
|
|
1288
|
+
const restored = { ...task, status: resumeSafeBacklog ? 'queued' : invalid.status, gateReconciliation: reconciled.reconciliation };
|
|
1289
|
+
delete restored.waitingGateId;
|
|
1290
|
+
delete restored.waitKind;
|
|
1291
|
+
delete restored.waitingSince;
|
|
1292
|
+
if (!resumeSafeBacklog) restored.canceledAt = now;
|
|
1293
|
+
else restored.requeuedAt = now;
|
|
1294
|
+
await writeJson(destinationFile, restored);
|
|
1280
1295
|
await rm(found.file, { force: true });
|
|
1281
1296
|
}
|
|
1282
1297
|
results.push({ queue, gateId: gate.gate_id, projectId: gate.project_id, outcome: invalid.status, reason: invalid.code });
|
|
@@ -2399,6 +2414,43 @@ function materializableDeferredGates(checkpoint) {
|
|
|
2399
2414
|
});
|
|
2400
2415
|
}
|
|
2401
2416
|
|
|
2417
|
+
async function projectBacklogItems(root, spec) {
|
|
2418
|
+
if (!spec) return [];
|
|
2419
|
+
const configured = spec.backlogSource ?? spec.authoritativeBacklog;
|
|
2420
|
+
if (!configured) return [];
|
|
2421
|
+
let loaded;
|
|
2422
|
+
try {
|
|
2423
|
+
loaded = await readJson(path.resolve(root, safeRelativePath(configured, 'project backlog source')));
|
|
2424
|
+
} catch {
|
|
2425
|
+
return [];
|
|
2426
|
+
}
|
|
2427
|
+
return Array.isArray(loaded.items) ? loaded.items : Array.isArray(loaded.tasks) ? loaded.tasks : [];
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
async function projectSpecForCheckpoint(root, specs, projectId, checkpoint) {
|
|
2431
|
+
const direct = specs.find((item) => item.project === projectId);
|
|
2432
|
+
const milestoneId = checkpoint?.milestone_id;
|
|
2433
|
+
if (!milestoneId) return direct;
|
|
2434
|
+
if ((await projectBacklogItems(root, direct)).some((item) => item.id === milestoneId)) return direct;
|
|
2435
|
+
for (const spec of specs) {
|
|
2436
|
+
if ((await projectBacklogItems(root, spec)).some((item) => item.id === milestoneId)) return spec;
|
|
2437
|
+
}
|
|
2438
|
+
return direct;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
async function projectHasSafeActionableBacklog(root, spec) {
|
|
2442
|
+
const items = await projectBacklogItems(root, spec);
|
|
2443
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
2444
|
+
const complete = new Set(['accepted', 'complete', 'completed', 'done', 'phase_complete']);
|
|
2445
|
+
const runnable = new Set(['pending', 'queued', 'ready', 'in_progress', 'active']);
|
|
2446
|
+
return items.some((item) => {
|
|
2447
|
+
if (!item?.id || !runnable.has(item.status)) return false;
|
|
2448
|
+
if (item.requires_human_input === true || item.blocked === true || item.status === 'blocked') return false;
|
|
2449
|
+
const dependencies = Array.isArray(item.dependsOn) ? item.dependsOn : Array.isArray(item.depends_on) ? item.depends_on : [];
|
|
2450
|
+
return dependencies.every((id) => complete.has(byId.get(id)?.status));
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2402
2454
|
async function tasksById(root, queue) {
|
|
2403
2455
|
const tasks = new Map();
|
|
2404
2456
|
for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
|
|
@@ -2452,9 +2504,14 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2452
2504
|
const deferredOnly = latestDeferredGates.length > 0
|
|
2453
2505
|
&& (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
|
|
2454
2506
|
&& !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
|
|
2455
|
-
const spec =
|
|
2507
|
+
const spec = await projectSpecForCheckpoint(root, specs, metadata.project_id, latestCheckpoint);
|
|
2456
2508
|
if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
|
|
2457
|
-
|
|
2509
|
+
// deferred_gates describe later authority boundaries. They become a
|
|
2510
|
+
// waiting gate only after the authoritative backlog has no unrelated safe
|
|
2511
|
+
// item left to run. Otherwise a future paid/deploy/publish boundary would
|
|
2512
|
+
// incorrectly stop local project development.
|
|
2513
|
+
if (deferredOnly && await projectHasSafeActionableBacklog(root, spec)) continue;
|
|
2514
|
+
candidates.push({ taskId, entry, projectId: spec?.project ?? metadata.project_id, ...checkpoints[0] });
|
|
2458
2515
|
}
|
|
2459
2516
|
|
|
2460
2517
|
// A project deferred gate is materialized only from its newest authoritative
|
|
@@ -8731,6 +8788,9 @@ const DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS = [
|
|
|
8731
8788
|
];
|
|
8732
8789
|
|
|
8733
8790
|
const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
|
|
8791
|
+
{ category: 'incomplete_turn', pattern: '"kind": "incomplete_turn"' },
|
|
8792
|
+
{ category: 'incomplete_turn', pattern: '"livenessState": "abandoned"' },
|
|
8793
|
+
{ category: 'incomplete_turn', pattern: 'stopped before confirming the turn was complete' },
|
|
8734
8794
|
{ category: 'compaction_timeout', pattern: 'compaction timed out' },
|
|
8735
8795
|
{ category: 'compaction_timeout', pattern: 'transcript compaction failed' },
|
|
8736
8796
|
{ category: 'transport_error', pattern: 'connection reset' },
|
|
@@ -8740,7 +8800,28 @@ const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
|
|
|
8740
8800
|
];
|
|
8741
8801
|
|
|
8742
8802
|
export function dispatchFailureClassification(result, retry = {}) {
|
|
8743
|
-
if (!result
|
|
8803
|
+
if (!result) {
|
|
8804
|
+
return {
|
|
8805
|
+
category: 'ok',
|
|
8806
|
+
requiresHumanAction: false,
|
|
8807
|
+
matchedPattern: null
|
|
8808
|
+
};
|
|
8809
|
+
}
|
|
8810
|
+
// Some embedded runtimes exit their wrapper successfully after the model
|
|
8811
|
+
// turn itself was abandoned. Inspect the structured trailer before trusting
|
|
8812
|
+
// exitCode=0 or the queue may accept partial work and replay a stale gate.
|
|
8813
|
+
const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
|
|
8814
|
+
const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
|
|
8815
|
+
const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
|
|
8816
|
+
if (runtimeMatch) {
|
|
8817
|
+
return {
|
|
8818
|
+
category: runtimeMatch.category ?? 'runtime_interruption',
|
|
8819
|
+
requiresHumanAction: false,
|
|
8820
|
+
recoverableRuntime: true,
|
|
8821
|
+
matchedPattern: runtimeMatch.pattern ?? runtimeMatch
|
|
8822
|
+
};
|
|
8823
|
+
}
|
|
8824
|
+
if (result.exitCode === 0) {
|
|
8744
8825
|
return {
|
|
8745
8826
|
category: 'ok',
|
|
8746
8827
|
requiresHumanAction: false,
|
|
@@ -8755,7 +8836,6 @@ export function dispatchFailureClassification(result, retry = {}) {
|
|
|
8755
8836
|
matchedPattern: null
|
|
8756
8837
|
};
|
|
8757
8838
|
}
|
|
8758
|
-
const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
|
|
8759
8839
|
const patterns = retry.requiresHumanActionPatterns ?? DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS;
|
|
8760
8840
|
const matched = patterns.find((pattern) => output.includes(pattern.toLowerCase()));
|
|
8761
8841
|
if (matched) {
|
|
@@ -8766,16 +8846,6 @@ export function dispatchFailureClassification(result, retry = {}) {
|
|
|
8766
8846
|
matchedPattern: matched
|
|
8767
8847
|
};
|
|
8768
8848
|
}
|
|
8769
|
-
const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
|
|
8770
|
-
const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
|
|
8771
|
-
if (runtimeMatch) {
|
|
8772
|
-
return {
|
|
8773
|
-
category: runtimeMatch.category ?? 'runtime_interruption',
|
|
8774
|
-
requiresHumanAction: false,
|
|
8775
|
-
recoverableRuntime: true,
|
|
8776
|
-
matchedPattern: runtimeMatch.pattern ?? runtimeMatch
|
|
8777
|
-
};
|
|
8778
|
-
}
|
|
8779
8849
|
return {
|
|
8780
8850
|
category: 'retryable_failure',
|
|
8781
8851
|
requiresHumanAction: false,
|
|
@@ -8908,9 +8978,11 @@ async function runDispatchWithRetry(root, options, queue, task, activeFile, runI
|
|
|
8908
8978
|
result,
|
|
8909
8979
|
failureClassification
|
|
8910
8980
|
});
|
|
8911
|
-
const dispatchStatus =
|
|
8912
|
-
? '
|
|
8913
|
-
:
|
|
8981
|
+
const dispatchStatus = failureClassification.recoverableRuntime
|
|
8982
|
+
? 'interrupted'
|
|
8983
|
+
: result.exitCode === 0
|
|
8984
|
+
? 'passed'
|
|
8985
|
+
: failureClassification.requiresHumanAction ? 'needs_human_action' : 'failed';
|
|
8914
8986
|
runContext.progress?.emit('dispatch', dispatchStatus, `Dispatcher attempt ${attempt} exited ${result.exitCode}`, {
|
|
8915
8987
|
attempt,
|
|
8916
8988
|
exitCode: result.exitCode,
|
|
@@ -9237,6 +9309,10 @@ export async function runQueueOnce(root, options) {
|
|
|
9237
9309
|
const dispatchClassification = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
|
|
9238
9310
|
if (dispatch?.canceled) {
|
|
9239
9311
|
finalStatus = 'superseded';
|
|
9312
|
+
} else if (dispatchClassification?.recoverableRuntime) {
|
|
9313
|
+
const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
|
|
9314
|
+
const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
|
|
9315
|
+
finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
|
|
9240
9316
|
} else if (dispatch?.exitCode === 0 && worktreeEnabled(options)) {
|
|
9241
9317
|
verification = await runVerifyCommands(options.worktree?.verifyCommands ?? [], worktree.path, timeoutMs, progress);
|
|
9242
9318
|
const verifyOk = verification.every((entry) => entry.result.exitCode === 0);
|
|
@@ -9246,10 +9322,6 @@ export async function runQueueOnce(root, options) {
|
|
|
9246
9322
|
finalStatus = 'completed';
|
|
9247
9323
|
} else if (dispatchClassification?.requiresHumanAction) {
|
|
9248
9324
|
finalStatus = 'needs_human_input';
|
|
9249
|
-
} else if (dispatchClassification?.recoverableRuntime) {
|
|
9250
|
-
const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
|
|
9251
|
-
const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
|
|
9252
|
-
finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
|
|
9253
9325
|
} else {
|
|
9254
9326
|
finalStatus = 'failed';
|
|
9255
9327
|
}
|
package/package.json
CHANGED
|
@@ -47,6 +47,36 @@ assert.equal(inferTaskScope({ body: 'Continue the overall project with a product
|
|
|
47
47
|
assert.equal(classification.recoverableRuntime, true);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
{
|
|
51
|
+
const classification = dispatchFailureClassification({
|
|
52
|
+
exitCode: 0,
|
|
53
|
+
timedOut: false,
|
|
54
|
+
stderr: '',
|
|
55
|
+
stdout: JSON.stringify({
|
|
56
|
+
replayInvalid: true,
|
|
57
|
+
livenessState: 'abandoned',
|
|
58
|
+
error: {
|
|
59
|
+
kind: 'incomplete_turn',
|
|
60
|
+
message: 'Codex stopped before confirming the turn was complete. Some work may already have been performed; verify the current state before retrying.'
|
|
61
|
+
}
|
|
62
|
+
}, null, 2)
|
|
63
|
+
});
|
|
64
|
+
assert.equal(classification.category, 'incomplete_turn');
|
|
65
|
+
assert.equal(classification.recoverableRuntime, true);
|
|
66
|
+
assert.equal(classification.requiresHumanAction, false);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
const classification = dispatchFailureClassification({
|
|
71
|
+
exitCode: 0,
|
|
72
|
+
timedOut: false,
|
|
73
|
+
stderr: '',
|
|
74
|
+
stdout: JSON.stringify({ livenessState: 'working', completion: { stopReason: 'stop' } })
|
|
75
|
+
});
|
|
76
|
+
assert.equal(classification.category, 'ok');
|
|
77
|
+
assert.equal(classification.recoverableRuntime, undefined);
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
{
|
|
51
81
|
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
52
82
|
const reviews = { reviews: [{ checkpointId: 'cp38', sequence: 38, status: 'accepted', projectCompletion: { status: 'in_progress' } }] };
|
|
@@ -111,6 +111,28 @@ assert.equal(deferredGate.authorization_requirements[0].action, 'production_roll
|
|
|
111
111
|
assert.match(deferredGate.authorization_requirements[0].required_authority, /Owner authorization/);
|
|
112
112
|
assert.equal((await queueStatus(root, queue)).waiting >= 1, true);
|
|
113
113
|
|
|
114
|
+
// A future authorization must not stop the project while an unrelated safe
|
|
115
|
+
// backlog item remains actionable.
|
|
116
|
+
await reconcileProjectGates(root, { queue });
|
|
117
|
+
await writeFile(authoritativeBacklog, `${JSON.stringify({
|
|
118
|
+
status: 'ongoing',
|
|
119
|
+
items: [
|
|
120
|
+
{ id: 'LOCAL-01', status: 'phase_complete', dependsOn: [] },
|
|
121
|
+
{ id: 'LOCAL-02', status: 'pending', dependsOn: ['LOCAL-01'] }
|
|
122
|
+
]
|
|
123
|
+
}, null, 2)}\n`);
|
|
124
|
+
const futureGateTask = await enqueueTask(root, { queue, title: 'OpenReel future production gate', task: 'Continue safe local backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
125
|
+
const futureGateDir = path.join(taskRuntimeDirFor(root, queue, futureGateTask.task.id), 'checkpoints');
|
|
126
|
+
await mkdir(futureGateDir, { recursive: true });
|
|
127
|
+
await writeFile(path.join(futureGateDir, 'cp1.json'), `${JSON.stringify({
|
|
128
|
+
version: 1, task_id: futureGateTask.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
|
|
129
|
+
project_completion: { status: 'in_progress' }, next_action: 'Implement LOCAL-02 locally.',
|
|
130
|
+
deferred_gates: [{ id: 'production-later', action: 'production_deploy', required_authority: 'Owner authorization after local candidate acceptance.' }]
|
|
131
|
+
}, null, 2)}\n`);
|
|
132
|
+
const futureNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
133
|
+
assert.equal(futureNotice.results.some((item) => item.taskId === futureGateTask.task.id), false);
|
|
134
|
+
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
135
|
+
|
|
114
136
|
// Conditional policy boundaries are not current blockers. Plain prose in
|
|
115
137
|
// deferred_gates must not materialize a gate without a concrete action and
|
|
116
138
|
// authority requirement.
|
|
@@ -145,4 +167,4 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
|
|
|
145
167
|
assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
|
|
146
168
|
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
147
169
|
|
|
148
|
-
console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'conditional policy prose does not create a gate', 'accepted project optional deferred gate stays out of queue'] }));
|
|
170
|
+
console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'accepted project optional deferred gate stays out of queue'] }));
|