taskforce-loop-engineering 0.15.4 → 0.15.6
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,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.6 - 2026-08-24
|
|
6
|
+
|
|
7
|
+
- Allow project configurations to use an external authoritative `backlogSource` without duplicating an embedded backlog registry.
|
|
8
|
+
- Keep project status fail-closed when neither an embedded backlog nor an external backlog source is configured.
|
|
9
|
+
- Suppress false registry-drift findings when no embedded projection is intentionally present, while retaining drift detection when both representations exist.
|
|
10
|
+
- Add regression coverage for external-only authoritative project backlogs.
|
|
11
|
+
|
|
12
|
+
## 0.15.5 - 2026-08-24
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
- Keep future paid, deployment, and publication authorization boundaries deferred while an unrelated safe project backlog item remains actionable.
|
|
16
|
+
- Reconcile an already-materialized premature deferred gate by superseding it and restoring the project carrier to the runnable queue.
|
|
17
|
+
- Resolve the authoritative project from checkpoint milestones when a task still carries a broader parent project id.
|
|
18
|
+
|
|
5
19
|
## 0.15.4 - 2026-08-22
|
|
6
20
|
|
|
7
21
|
- 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 });
|
|
@@ -1654,7 +1669,12 @@ function validateProjectSpec(spec) {
|
|
|
1654
1669
|
normalizeLoopId(queue.queue);
|
|
1655
1670
|
if (!['code', 'standard'].includes(queue.kind)) throw new Error(`Unsupported project queue kind: ${queue.kind}`);
|
|
1656
1671
|
}
|
|
1657
|
-
|
|
1672
|
+
const hasEmbeddedBacklog = Array.isArray(spec.backlog) && spec.backlog.length > 0;
|
|
1673
|
+
const configuredBacklogSource = spec.backlogSource ?? spec.authoritativeBacklog;
|
|
1674
|
+
const hasConfiguredBacklog = typeof configuredBacklogSource === 'string' && configuredBacklogSource.trim().length > 0;
|
|
1675
|
+
if (!hasEmbeddedBacklog && !hasConfiguredBacklog) {
|
|
1676
|
+
throw new Error('Project spec must provide a non-empty backlog or backlogSource.');
|
|
1677
|
+
}
|
|
1658
1678
|
}
|
|
1659
1679
|
|
|
1660
1680
|
export async function projectPlan(root, options = {}) {
|
|
@@ -1786,17 +1806,20 @@ export async function projectStatus(root, options = {}) {
|
|
|
1786
1806
|
acceptanceLedger = { file: path.relative(root, ledgerFile), readable: false, error: error.message };
|
|
1787
1807
|
}
|
|
1788
1808
|
}
|
|
1789
|
-
const
|
|
1809
|
+
const hasEmbeddedBacklog = Array.isArray(spec.backlog) && spec.backlog.length > 0;
|
|
1810
|
+
const registryItems = hasEmbeddedBacklog ? spec.backlog : [];
|
|
1790
1811
|
const sourceItems = backlog ? await readJson(backlogFile).then((loaded) => loaded.items ?? loaded.tasks ?? []) : [];
|
|
1791
1812
|
const registryById = new Map(registryItems.map((item) => [item.id, item]));
|
|
1792
1813
|
const sourceById = new Map(sourceItems.map((item) => [item.id, item]));
|
|
1793
1814
|
const drift = [];
|
|
1794
|
-
|
|
1795
|
-
const
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1815
|
+
if (hasEmbeddedBacklog) {
|
|
1816
|
+
for (const id of new Set([...registryById.keys(), ...sourceById.keys()])) {
|
|
1817
|
+
const registry = registryById.get(id);
|
|
1818
|
+
const authoritative = sourceById.get(id);
|
|
1819
|
+
if (!registry || !authoritative) drift.push({ id, kind: registry ? 'missing_from_authoritative_backlog' : 'missing_from_registry' });
|
|
1820
|
+
else if (registry.status !== authoritative.status || Boolean(registry.required) !== Boolean(authoritative.required)) {
|
|
1821
|
+
drift.push({ id, kind: 'status_or_scope_mismatch', registry: { status: registry.status, required: registry.required }, authoritative: { status: authoritative.status, required: authoritative.required } });
|
|
1822
|
+
}
|
|
1800
1823
|
}
|
|
1801
1824
|
}
|
|
1802
1825
|
const projectCompletion = acceptanceLedger?.status === 'accepted' && acceptanceLedger.unmet.length === 0 && acceptanceLedger.blockers.length === 0
|
|
@@ -2399,6 +2422,43 @@ function materializableDeferredGates(checkpoint) {
|
|
|
2399
2422
|
});
|
|
2400
2423
|
}
|
|
2401
2424
|
|
|
2425
|
+
async function projectBacklogItems(root, spec) {
|
|
2426
|
+
if (!spec) return [];
|
|
2427
|
+
const configured = spec.backlogSource ?? spec.authoritativeBacklog;
|
|
2428
|
+
if (!configured) return [];
|
|
2429
|
+
let loaded;
|
|
2430
|
+
try {
|
|
2431
|
+
loaded = await readJson(path.resolve(root, safeRelativePath(configured, 'project backlog source')));
|
|
2432
|
+
} catch {
|
|
2433
|
+
return [];
|
|
2434
|
+
}
|
|
2435
|
+
return Array.isArray(loaded.items) ? loaded.items : Array.isArray(loaded.tasks) ? loaded.tasks : [];
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
async function projectSpecForCheckpoint(root, specs, projectId, checkpoint) {
|
|
2439
|
+
const direct = specs.find((item) => item.project === projectId);
|
|
2440
|
+
const milestoneId = checkpoint?.milestone_id;
|
|
2441
|
+
if (!milestoneId) return direct;
|
|
2442
|
+
if ((await projectBacklogItems(root, direct)).some((item) => item.id === milestoneId)) return direct;
|
|
2443
|
+
for (const spec of specs) {
|
|
2444
|
+
if ((await projectBacklogItems(root, spec)).some((item) => item.id === milestoneId)) return spec;
|
|
2445
|
+
}
|
|
2446
|
+
return direct;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
async function projectHasSafeActionableBacklog(root, spec) {
|
|
2450
|
+
const items = await projectBacklogItems(root, spec);
|
|
2451
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
2452
|
+
const complete = new Set(['accepted', 'complete', 'completed', 'done', 'phase_complete']);
|
|
2453
|
+
const runnable = new Set(['pending', 'queued', 'ready', 'in_progress', 'active']);
|
|
2454
|
+
return items.some((item) => {
|
|
2455
|
+
if (!item?.id || !runnable.has(item.status)) return false;
|
|
2456
|
+
if (item.requires_human_input === true || item.blocked === true || item.status === 'blocked') return false;
|
|
2457
|
+
const dependencies = Array.isArray(item.dependsOn) ? item.dependsOn : Array.isArray(item.depends_on) ? item.depends_on : [];
|
|
2458
|
+
return dependencies.every((id) => complete.has(byId.get(id)?.status));
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2402
2462
|
async function tasksById(root, queue) {
|
|
2403
2463
|
const tasks = new Map();
|
|
2404
2464
|
for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
|
|
@@ -2452,9 +2512,14 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2452
2512
|
const deferredOnly = latestDeferredGates.length > 0
|
|
2453
2513
|
&& (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
|
|
2454
2514
|
&& !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
|
|
2455
|
-
const spec =
|
|
2515
|
+
const spec = await projectSpecForCheckpoint(root, specs, metadata.project_id, latestCheckpoint);
|
|
2456
2516
|
if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
|
|
2457
|
-
|
|
2517
|
+
// deferred_gates describe later authority boundaries. They become a
|
|
2518
|
+
// waiting gate only after the authoritative backlog has no unrelated safe
|
|
2519
|
+
// item left to run. Otherwise a future paid/deploy/publish boundary would
|
|
2520
|
+
// incorrectly stop local project development.
|
|
2521
|
+
if (deferredOnly && await projectHasSafeActionableBacklog(root, spec)) continue;
|
|
2522
|
+
candidates.push({ taskId, entry, projectId: spec?.project ?? metadata.project_id, ...checkpoints[0] });
|
|
2458
2523
|
}
|
|
2459
2524
|
|
|
2460
2525
|
// A project deferred gate is materialized only from its newest authoritative
|
|
@@ -8731,6 +8796,9 @@ const DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS = [
|
|
|
8731
8796
|
];
|
|
8732
8797
|
|
|
8733
8798
|
const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
|
|
8799
|
+
{ category: 'incomplete_turn', pattern: '"kind": "incomplete_turn"' },
|
|
8800
|
+
{ category: 'incomplete_turn', pattern: '"livenessState": "abandoned"' },
|
|
8801
|
+
{ category: 'incomplete_turn', pattern: 'stopped before confirming the turn was complete' },
|
|
8734
8802
|
{ category: 'compaction_timeout', pattern: 'compaction timed out' },
|
|
8735
8803
|
{ category: 'compaction_timeout', pattern: 'transcript compaction failed' },
|
|
8736
8804
|
{ category: 'transport_error', pattern: 'connection reset' },
|
|
@@ -8740,7 +8808,28 @@ const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
|
|
|
8740
8808
|
];
|
|
8741
8809
|
|
|
8742
8810
|
export function dispatchFailureClassification(result, retry = {}) {
|
|
8743
|
-
if (!result
|
|
8811
|
+
if (!result) {
|
|
8812
|
+
return {
|
|
8813
|
+
category: 'ok',
|
|
8814
|
+
requiresHumanAction: false,
|
|
8815
|
+
matchedPattern: null
|
|
8816
|
+
};
|
|
8817
|
+
}
|
|
8818
|
+
// Some embedded runtimes exit their wrapper successfully after the model
|
|
8819
|
+
// turn itself was abandoned. Inspect the structured trailer before trusting
|
|
8820
|
+
// exitCode=0 or the queue may accept partial work and replay a stale gate.
|
|
8821
|
+
const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
|
|
8822
|
+
const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
|
|
8823
|
+
const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
|
|
8824
|
+
if (runtimeMatch) {
|
|
8825
|
+
return {
|
|
8826
|
+
category: runtimeMatch.category ?? 'runtime_interruption',
|
|
8827
|
+
requiresHumanAction: false,
|
|
8828
|
+
recoverableRuntime: true,
|
|
8829
|
+
matchedPattern: runtimeMatch.pattern ?? runtimeMatch
|
|
8830
|
+
};
|
|
8831
|
+
}
|
|
8832
|
+
if (result.exitCode === 0) {
|
|
8744
8833
|
return {
|
|
8745
8834
|
category: 'ok',
|
|
8746
8835
|
requiresHumanAction: false,
|
|
@@ -8755,7 +8844,6 @@ export function dispatchFailureClassification(result, retry = {}) {
|
|
|
8755
8844
|
matchedPattern: null
|
|
8756
8845
|
};
|
|
8757
8846
|
}
|
|
8758
|
-
const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
|
|
8759
8847
|
const patterns = retry.requiresHumanActionPatterns ?? DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS;
|
|
8760
8848
|
const matched = patterns.find((pattern) => output.includes(pattern.toLowerCase()));
|
|
8761
8849
|
if (matched) {
|
|
@@ -8766,16 +8854,6 @@ export function dispatchFailureClassification(result, retry = {}) {
|
|
|
8766
8854
|
matchedPattern: matched
|
|
8767
8855
|
};
|
|
8768
8856
|
}
|
|
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
8857
|
return {
|
|
8780
8858
|
category: 'retryable_failure',
|
|
8781
8859
|
requiresHumanAction: false,
|
|
@@ -8908,9 +8986,11 @@ async function runDispatchWithRetry(root, options, queue, task, activeFile, runI
|
|
|
8908
8986
|
result,
|
|
8909
8987
|
failureClassification
|
|
8910
8988
|
});
|
|
8911
|
-
const dispatchStatus =
|
|
8912
|
-
? '
|
|
8913
|
-
:
|
|
8989
|
+
const dispatchStatus = failureClassification.recoverableRuntime
|
|
8990
|
+
? 'interrupted'
|
|
8991
|
+
: result.exitCode === 0
|
|
8992
|
+
? 'passed'
|
|
8993
|
+
: failureClassification.requiresHumanAction ? 'needs_human_action' : 'failed';
|
|
8914
8994
|
runContext.progress?.emit('dispatch', dispatchStatus, `Dispatcher attempt ${attempt} exited ${result.exitCode}`, {
|
|
8915
8995
|
attempt,
|
|
8916
8996
|
exitCode: result.exitCode,
|
|
@@ -9237,6 +9317,10 @@ export async function runQueueOnce(root, options) {
|
|
|
9237
9317
|
const dispatchClassification = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
|
|
9238
9318
|
if (dispatch?.canceled) {
|
|
9239
9319
|
finalStatus = 'superseded';
|
|
9320
|
+
} else if (dispatchClassification?.recoverableRuntime) {
|
|
9321
|
+
const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
|
|
9322
|
+
const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
|
|
9323
|
+
finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
|
|
9240
9324
|
} else if (dispatch?.exitCode === 0 && worktreeEnabled(options)) {
|
|
9241
9325
|
verification = await runVerifyCommands(options.worktree?.verifyCommands ?? [], worktree.path, timeoutMs, progress);
|
|
9242
9326
|
const verifyOk = verification.every((entry) => entry.result.exitCode === 0);
|
|
@@ -9246,10 +9330,6 @@ export async function runQueueOnce(root, options) {
|
|
|
9246
9330
|
finalStatus = 'completed';
|
|
9247
9331
|
} else if (dispatchClassification?.requiresHumanAction) {
|
|
9248
9332
|
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
9333
|
} else {
|
|
9254
9334
|
finalStatus = 'failed';
|
|
9255
9335
|
}
|
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' } }] };
|
|
@@ -91,6 +91,27 @@ assert.equal(drifted.authority.acceptanceLedger, 'project/acceptance-ledger.json
|
|
|
91
91
|
assert.equal(drifted.consistency.ok, false);
|
|
92
92
|
assert(drifted.needsAttention.includes('authoritative_source_drift'));
|
|
93
93
|
|
|
94
|
+
// A project may keep its authoritative backlog exclusively in backlogSource.
|
|
95
|
+
// Requiring a duplicate embedded backlog makes the two copies drift and used
|
|
96
|
+
// to prevent project-status from reading an otherwise valid project ledger.
|
|
97
|
+
await writeFile(projectFile, `${JSON.stringify({
|
|
98
|
+
...spec,
|
|
99
|
+
backlog: undefined,
|
|
100
|
+
backlogSource: 'project/backlog.json',
|
|
101
|
+
acceptanceLedger: 'project/acceptance-ledger.json',
|
|
102
|
+
terminalContract: 'project/terminal.md'
|
|
103
|
+
}, null, 2)}\n`);
|
|
104
|
+
const externalOnly = await projectStatus(root, { project: 'openreel' });
|
|
105
|
+
assert.equal(externalOnly.backlog.file, 'project/backlog.json');
|
|
106
|
+
assert.equal(externalOnly.backlog.count, 1);
|
|
107
|
+
assert.equal(externalOnly.consistency.ok, true);
|
|
108
|
+
await writeFile(projectFile, `${JSON.stringify({
|
|
109
|
+
...spec,
|
|
110
|
+
backlogSource: 'project/backlog.json',
|
|
111
|
+
acceptanceLedger: 'project/acceptance-ledger.json',
|
|
112
|
+
terminalContract: 'project/terminal.md'
|
|
113
|
+
}, null, 2)}\n`);
|
|
114
|
+
|
|
94
115
|
// A ready milestone with project in progress and a deferred authorization is
|
|
95
116
|
// converted into a structured waiting gate, not left as prose on a done task.
|
|
96
117
|
const deferred = await enqueueTask(root, { queue, title: 'OpenReel deferred S-01', task: 'Project openreel S-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
@@ -111,6 +132,28 @@ assert.equal(deferredGate.authorization_requirements[0].action, 'production_roll
|
|
|
111
132
|
assert.match(deferredGate.authorization_requirements[0].required_authority, /Owner authorization/);
|
|
112
133
|
assert.equal((await queueStatus(root, queue)).waiting >= 1, true);
|
|
113
134
|
|
|
135
|
+
// A future authorization must not stop the project while an unrelated safe
|
|
136
|
+
// backlog item remains actionable.
|
|
137
|
+
await reconcileProjectGates(root, { queue });
|
|
138
|
+
await writeFile(authoritativeBacklog, `${JSON.stringify({
|
|
139
|
+
status: 'ongoing',
|
|
140
|
+
items: [
|
|
141
|
+
{ id: 'LOCAL-01', status: 'phase_complete', dependsOn: [] },
|
|
142
|
+
{ id: 'LOCAL-02', status: 'pending', dependsOn: ['LOCAL-01'] }
|
|
143
|
+
]
|
|
144
|
+
}, null, 2)}\n`);
|
|
145
|
+
const futureGateTask = await enqueueTask(root, { queue, title: 'OpenReel future production gate', task: 'Continue safe local backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
146
|
+
const futureGateDir = path.join(taskRuntimeDirFor(root, queue, futureGateTask.task.id), 'checkpoints');
|
|
147
|
+
await mkdir(futureGateDir, { recursive: true });
|
|
148
|
+
await writeFile(path.join(futureGateDir, 'cp1.json'), `${JSON.stringify({
|
|
149
|
+
version: 1, task_id: futureGateTask.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
|
|
150
|
+
project_completion: { status: 'in_progress' }, next_action: 'Implement LOCAL-02 locally.',
|
|
151
|
+
deferred_gates: [{ id: 'production-later', action: 'production_deploy', required_authority: 'Owner authorization after local candidate acceptance.' }]
|
|
152
|
+
}, null, 2)}\n`);
|
|
153
|
+
const futureNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
154
|
+
assert.equal(futureNotice.results.some((item) => item.taskId === futureGateTask.task.id), false);
|
|
155
|
+
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
156
|
+
|
|
114
157
|
// Conditional policy boundaries are not current blockers. Plain prose in
|
|
115
158
|
// deferred_gates must not materialize a gate without a concrete action and
|
|
116
159
|
// authority requirement.
|
|
@@ -145,4 +188,4 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
|
|
|
145
188
|
assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
|
|
146
189
|
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
147
190
|
|
|
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'] }));
|
|
191
|
+
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'] }));
|