@tiphys/kernel 0.0.0 → 0.2.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/AGENTS.md +663 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +299 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +92 -0
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +66 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +1539 -0
- package/dist/src/checks.js +5025 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +68 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +343 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +264 -0
- package/dist/src/commands/doctor.js +1546 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +177 -0
- package/dist/src/commands/lock.d.ts +34 -0
- package/dist/src/commands/lock.js +340 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +139 -0
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +195 -0
- package/dist/src/commands/status.d.ts +21 -0
- package/dist/src/commands/status.js +122 -0
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +87 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +430 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +278 -0
- package/dist/src/exec/env.js +334 -0
- package/dist/src/fleet.d.ts +223 -0
- package/dist/src/fleet.js +298 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1169 -0
- package/dist/src/gates/coverage.d.ts +375 -0
- package/dist/src/gates/coverage.js +836 -0
- package/dist/src/gates/credentials.d.ts +233 -0
- package/dist/src/gates/credentials.js +752 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +482 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +614 -0
- package/dist/src/gates/run.js +1614 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +968 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +84 -0
- package/dist/src/hooks.js +125 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +256 -0
- package/dist/src/lock.js +792 -0
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +34 -0
- package/dist/src/path-identity.js +48 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +326 -0
- package/dist/src/pool.js +988 -0
- package/dist/src/roles.d.ts +461 -0
- package/dist/src/roles.js +776 -0
- package/dist/src/spawn.d.ts +482 -0
- package/dist/src/spawn.js +1003 -0
- package/dist/src/status.d.ts +116 -0
- package/dist/src/status.js +148 -0
- package/dist/src/task.d.ts +504 -0
- package/dist/src/task.js +307 -0
- package/dist/src/teardown.d.ts +39 -0
- package/dist/src/teardown.js +422 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +263 -0
- package/dist/src/validate.js +742 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +849 -0
- package/dist/src/witness/run.d.ts +299 -0
- package/dist/src/witness/run.js +1373 -0
- package/dist/src/witness/spec.d.ts +270 -0
- package/dist/src/witness/spec.js +475 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +526 -0
- package/gates.manifest.json +257 -0
- package/package.json +72 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +463 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +82 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +185 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +295 -0
- package/schemas/work-history.schema.json +183 -0
- package/schemas/write-bypass.schema.json +69 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { constants } from "node:os";
|
|
4
|
+
import { BUILT_IN_ADAPTER_NAME, selectAdapter } from "./adapters/load.js";
|
|
5
|
+
import { assembleBrief } from "./brief.js";
|
|
6
|
+
import { CREDENTIAL_STORE_REDIRECTIONS, buildChildEnv, extensionName, extensionReason, refuseExtraAllowlist, scrubRoot, } from "./exec/env.js";
|
|
7
|
+
import { guardSharedRegister } from "./exclusion.js";
|
|
8
|
+
import { writeTurnEndHook } from "./hooks.js";
|
|
9
|
+
import { poolCreate, poolDestroy, worktreePath } from "./pool.js";
|
|
10
|
+
import { checkHoldership, executorRecordPath, metaPath, readRegularFileIfPresent, runStep, runStepAsync, taskDir, taskDirExists, taskDirOccupied, turnEndPath, writeTaskMeta, } from "./task.js";
|
|
11
|
+
/**
|
|
12
|
+
* THE FIELDS AN ADAPTER MAY NAME IN `requires`, AND WHETHER EACH ONE CAN BE
|
|
13
|
+
* ABSENT (M4-P3 criteria 2 and 3).
|
|
14
|
+
*
|
|
15
|
+
* This is the ONE source for both halves of the requirement check, and it is
|
|
16
|
+
* one source on purpose: a list of legal names maintained beside a separate
|
|
17
|
+
* list of presence tests is two things that drift, and the drift is silent in
|
|
18
|
+
* exactly the direction that matters (a new request field nobody can require,
|
|
19
|
+
* or a requirable name nothing can satisfy). `requirableRequestFields` reads
|
|
20
|
+
* its answer back out of this function, so there is nothing to keep in step.
|
|
21
|
+
*
|
|
22
|
+
* The six names set to `true` unconditionally are the ones the KERNEL
|
|
23
|
+
* produces: it has a task id because it was given one, a worktree and a
|
|
24
|
+
* record path because it computed them, a command because it refused an empty
|
|
25
|
+
* one, a hook path because it wrote the hook, and a brief path because
|
|
26
|
+
* `assembleBrief` returned one. None of them can be absent by the time the
|
|
27
|
+
* request is built, and `test/spawn.test.ts` checks that claim against the
|
|
28
|
+
* request an adapter is actually handed rather than leaving it asserted here.
|
|
29
|
+
*/
|
|
30
|
+
function requestFieldPresence(options) {
|
|
31
|
+
const presence = new Map();
|
|
32
|
+
for (const name of ["taskId", "worktree", "command", "hookPath", "recordPath", "briefPath"]) {
|
|
33
|
+
presence.set(name, true);
|
|
34
|
+
}
|
|
35
|
+
presence.set("deadlineSeconds", options.deadlineSeconds !== undefined);
|
|
36
|
+
presence.set("env", options.allowPrCredentials !== true);
|
|
37
|
+
presence.set("role", options.role !== undefined);
|
|
38
|
+
presence.set("declaredTier", options.declaredTier !== undefined);
|
|
39
|
+
presence.set("phaseId", options.phaseId !== undefined);
|
|
40
|
+
return presence;
|
|
41
|
+
}
|
|
42
|
+
/** The closed set of names an adapter may name in `requires`, in field order. */
|
|
43
|
+
export function requirableRequestFields() {
|
|
44
|
+
return [
|
|
45
|
+
...requestFieldPresence({
|
|
46
|
+
taskId: "",
|
|
47
|
+
project: "",
|
|
48
|
+
briefFile: "",
|
|
49
|
+
shape: "ship",
|
|
50
|
+
exec: "",
|
|
51
|
+
deadlineSeconds: undefined,
|
|
52
|
+
offline: false,
|
|
53
|
+
// M4-P8: a REQUIRED SpawnOptions field, and deliberately NOT a
|
|
54
|
+
// requirable request field. `requestFieldPresence` maps names an
|
|
55
|
+
// adapter may declare in `requires`, and every one of them is a field
|
|
56
|
+
// of `ExecutorRequest`; the payload class never crosses that seam, so
|
|
57
|
+
// it appears here only because the literal must typecheck.
|
|
58
|
+
payloadClass: "project",
|
|
59
|
+
role: undefined,
|
|
60
|
+
declaredTier: undefined,
|
|
61
|
+
phaseId: undefined,
|
|
62
|
+
}).keys(),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* THE ADAPTER CONTRACT CHECK (M4-P3 criterion 3): a requirement naming a
|
|
67
|
+
* field that does not exist is a DEFECT IN THE ADAPTER, refused when the
|
|
68
|
+
* adapter is taken up and before anything is created.
|
|
69
|
+
*
|
|
70
|
+
* IT IS A DIFFERENT QUESTION FROM `checkAdapterRequirements` BELOW, AND THE
|
|
71
|
+
* ORDER IS LOAD-BEARING. The presence test there answers "is this field
|
|
72
|
+
* absent", and an unknown key is absent too, so a single check written that
|
|
73
|
+
* way would answer a defect in the adapter with a message about a missing
|
|
74
|
+
* flag: an operator would go looking for a `--modelName` that the kernel has
|
|
75
|
+
* no field for and could never accept. Running this one FIRST is what keeps
|
|
76
|
+
* the two answers distinct, and `test/spawn.test.ts` asserts that they say
|
|
77
|
+
* structurally different things rather than merely both refusing.
|
|
78
|
+
*/
|
|
79
|
+
export function checkAdapterContract(adapter) {
|
|
80
|
+
const known = requirableRequestFields();
|
|
81
|
+
// THE DECLARATION IS FOREIGN INPUT, not a value this module produced. From
|
|
82
|
+
// M4-P4 an adapter is resolved from the fleet home, so `requires` arrives
|
|
83
|
+
// from a module the kernel did not write and the TYPE IS A PROMISE, not a
|
|
84
|
+
// guarantee. A missing or non-array declaration reaching `.filter` would be
|
|
85
|
+
// a TypeError raised out of `spawnTask` with no reason line, which is the
|
|
86
|
+
// same "refusal that arrives as a crash" shape the rest of this check
|
|
87
|
+
// exists to prevent, one level lower.
|
|
88
|
+
if (!Array.isArray(adapter.requires)) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: `the ${adapter.name} adapter does not declare requires as an array, so the ` +
|
|
92
|
+
`kernel cannot tell what it needs; an adapter that needs nothing declares []`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const unknown = adapter.requires.filter((name) => !known.includes(name));
|
|
96
|
+
if (unknown.length === 0) {
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: `the ${adapter.name} adapter declares a requirement on ${unknown.join(", ")}, ` +
|
|
102
|
+
`which the executor request contract has no field for; the requirable ` +
|
|
103
|
+
`fields are ${known.join(", ")}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* THE DECLARED-REQUIREMENT CHECK (M4-P3 criterion 2): a field the adapter
|
|
108
|
+
* declared it needs, and the spawn was not given, is a USAGE ERROR, and a
|
|
109
|
+
* usage error creates nothing.
|
|
110
|
+
*
|
|
111
|
+
* THE DANGEROUS STATE THIS EXISTS FOR is not "an adapter gets undefined". It
|
|
112
|
+
* is an adapter DISCOVERING that it got undefined, and raising, after pool
|
|
113
|
+
* create has already made a worktree, a branch and a pool record. That is the
|
|
114
|
+
* measured `--deadline` defect at src/commands/spawn.ts:81 with a new field:
|
|
115
|
+
* a value the kernel could not represent used to raise inside the adapter,
|
|
116
|
+
* after the creation, and the repair was to refuse at parse time. This is the
|
|
117
|
+
* same repair one field along, and it is the reason the check runs at the top
|
|
118
|
+
* of `spawnTask` rather than beside the launch where the request is built.
|
|
119
|
+
*/
|
|
120
|
+
export function checkAdapterRequirements(adapter, options) {
|
|
121
|
+
const presence = requestFieldPresence(options);
|
|
122
|
+
const unmet = adapter.requires.filter((name) => presence.get(name) !== true);
|
|
123
|
+
if (unmet.length === 0) {
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: `the ${adapter.name} adapter requires ${unmet.join(", ")}, and this spawn ` +
|
|
129
|
+
`supplied no value for ${unmet.length === 1 ? "it" : "them"}; nothing was created`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* THE CREDENTIAL POLICY CHECK (M4-P8 steps 2, 3 and 5), and it runs before
|
|
134
|
+
* ANYTHING is resolved, loaded or created.
|
|
135
|
+
*
|
|
136
|
+
* Three refusals, in this order, and the order is the fail-closed one:
|
|
137
|
+
*
|
|
138
|
+
* 1. an absent or unrecognised `payloadClass`. Checked first so that
|
|
139
|
+
* refusal 2 never has to reason about an unknown value: without this,
|
|
140
|
+
* `payloadClass !== "project"` would read an omission as permission.
|
|
141
|
+
* 2. the declared escape hatch asked for on a PROJECT payload. This is
|
|
142
|
+
* the pairing the phase exists to refuse: `allowPrCredentials` hands
|
|
143
|
+
* the parent environment over UNCHANGED (see ExecutorRequest.env),
|
|
144
|
+
* credentials and all, and M2-P8 criterion 1 wrote it for the
|
|
145
|
+
* orchestrator's own spawns only. Until this phase the option was
|
|
146
|
+
* reachable from the library seam with nothing between it and a
|
|
147
|
+
* project payload.
|
|
148
|
+
* 3. an extension entry the child must not carry, which is the same
|
|
149
|
+
* vocabulary check `buildChildEnv` makes, made EARLIER. The
|
|
150
|
+
* duplication is deliberate and is not two implementations: both call
|
|
151
|
+
* `refuseExtraAllowlist`. Making it here as well is what keeps a
|
|
152
|
+
* rejected widening from costing a worktree, a branch and a pool
|
|
153
|
+
* record, because `buildChildEnv` does not run until after pool
|
|
154
|
+
* create.
|
|
155
|
+
*
|
|
156
|
+
* It returns a reason rather than throwing, because every refusal on this
|
|
157
|
+
* path must be able to say "nothing was created" in the same sentence.
|
|
158
|
+
*/
|
|
159
|
+
export function checkCredentialPolicy(options) {
|
|
160
|
+
const declared = ["orchestrator", "project"];
|
|
161
|
+
if (!declared.includes(options.payloadClass)) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
reason: `this spawn declares no payload class (payloadClass was ` +
|
|
165
|
+
`${JSON.stringify(options.payloadClass)}); it is required and has no ` +
|
|
166
|
+
`default, because an omitted class would otherwise take the ` +
|
|
167
|
+
`orchestrator's authority by default: pass one of ` +
|
|
168
|
+
`${declared.join(", ")}; nothing was created`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (options.allowPrCredentials === true && options.payloadClass === "project") {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: `allowPrCredentials is the declared escape hatch from the credential ` +
|
|
175
|
+
`scrub and hands the parent environment over unchanged, so it may not ` +
|
|
176
|
+
`be combined with payloadClass "project": a project payload never ` +
|
|
177
|
+
`receives the orchestrator's credentials; nothing was created`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// `reason-required`: THIS is the audited route (DR-0039 condition 2, M4-P8
|
|
181
|
+
// criterion 4), and the argument is passed explicitly rather than
|
|
182
|
+
// defaulted, because a default would be the same "absent takes the
|
|
183
|
+
// permissive arm" shape CR-B-002 is.
|
|
184
|
+
const extensionRefusal = refuseExtraAllowlist(options.extraAllowlist ?? [], "reason-required");
|
|
185
|
+
if (extensionRefusal !== undefined) {
|
|
186
|
+
return { ok: false, reason: `${extensionRefusal}; nothing was created` };
|
|
187
|
+
}
|
|
188
|
+
return { ok: true };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Render `redirectionSource` into the refusal sentence. It names the ARTIFACT
|
|
192
|
+
* the values were read from, never a party that observed them: the turn-end
|
|
193
|
+
* record is adapter-reachable (src/hooks.ts), so a phrase like "observed
|
|
194
|
+
* child-side", which is what this said until the DR-0047 sweep fix round,
|
|
195
|
+
* asserts more than the kernel checked (CR-F-CRED-001).
|
|
196
|
+
*/
|
|
197
|
+
function redirectionSourcePhrase(source) {
|
|
198
|
+
if (source === "turn-end-record") {
|
|
199
|
+
return "read from the turn-end record, which the kernel generates the hook for and an adapter can also write";
|
|
200
|
+
}
|
|
201
|
+
if (source === "adapter") {
|
|
202
|
+
return "read from the adapter's own launch report";
|
|
203
|
+
}
|
|
204
|
+
return "read from no pointer evidence at all";
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* COMPARE THE HANDOVER (M4-P8 criterion 6, repaired for CR-B-001).
|
|
208
|
+
*
|
|
209
|
+
* `handed` is what the kernel built and passed, `reported` is the name set
|
|
210
|
+
* the adapter says it launched with, `pointers` is what the five
|
|
211
|
+
* CREDENTIAL_STORE_REDIRECTIONS actually were where the payload ran.
|
|
212
|
+
*
|
|
213
|
+
* TWO PROPERTIES, AND THE STATUS SAYS WHICH WERE CHECKED. The name-set
|
|
214
|
+
* comparison alone used to be written down as `compared`, which an operator
|
|
215
|
+
* reads as "the handover was verified"; an adapter that keeps the name set
|
|
216
|
+
* byte-identical and puts `HOME` and `XDG_CONFIG_HOME` back to their real
|
|
217
|
+
* paths defeats the M2R-004 defense entirely and was recorded as clean. See
|
|
218
|
+
* `CredentialHandoverRecord` for the five status values and for why the
|
|
219
|
+
* VALUES are still never written into the record.
|
|
220
|
+
*
|
|
221
|
+
* The pointer comparison is by value and the values are DISCARDED: only the
|
|
222
|
+
* names that differ survive into `changedRedirections`. A name the kernel
|
|
223
|
+
* never handed over is not compared, because there is no handed value to
|
|
224
|
+
* compare it against, and the name-set arms are what speak to that case.
|
|
225
|
+
*/
|
|
226
|
+
export function compareHandover(handed, reported, pointers) {
|
|
227
|
+
if (handed === undefined) {
|
|
228
|
+
return {
|
|
229
|
+
status: "not-applicable",
|
|
230
|
+
added: [],
|
|
231
|
+
removed: [],
|
|
232
|
+
changedRedirections: [],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const changedRedirections = pointers === undefined
|
|
236
|
+
? []
|
|
237
|
+
: CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => redirection.name)
|
|
238
|
+
.filter((name) => {
|
|
239
|
+
const handedValue = handed[name];
|
|
240
|
+
if (handedValue === undefined) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return pointers.values[name] !== handedValue;
|
|
244
|
+
})
|
|
245
|
+
.sort();
|
|
246
|
+
const pointerPart = pointers === undefined ? {} : { redirectionSource: pointers.source };
|
|
247
|
+
if (reported === undefined) {
|
|
248
|
+
return {
|
|
249
|
+
status: pointers === undefined ? "unreported" : "pointers-compared",
|
|
250
|
+
added: [],
|
|
251
|
+
removed: [],
|
|
252
|
+
changedRedirections,
|
|
253
|
+
...pointerPart,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const handedNames = new Set(Object.keys(handed));
|
|
257
|
+
const reportedNames = new Set(reported);
|
|
258
|
+
return {
|
|
259
|
+
status: pointers === undefined ? "names-compared" : "compared",
|
|
260
|
+
added: [...reportedNames].filter((name) => !handedNames.has(name)).sort(),
|
|
261
|
+
removed: [...handedNames].filter((name) => !reportedNames.has(name)).sort(),
|
|
262
|
+
changedRedirections,
|
|
263
|
+
...pointerPart,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* A payload terminated by a signal has no exit code. The shell's
|
|
268
|
+
* convention (128 + signal number) is used so the turn-end record always
|
|
269
|
+
* carries a number. M1 never drives this path; it exists so that the
|
|
270
|
+
* type is honest rather than filled with a guess.
|
|
271
|
+
*/
|
|
272
|
+
function payloadExitCode(status, signal) {
|
|
273
|
+
if (status !== null) {
|
|
274
|
+
return status;
|
|
275
|
+
}
|
|
276
|
+
const signals = constants.signals;
|
|
277
|
+
const number = signal === null ? undefined : signals[signal];
|
|
278
|
+
return 128 + (number ?? 0);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The local subprocess adapter, the one M1 ships (and the one the exit
|
|
282
|
+
* test's stub payload uses).
|
|
283
|
+
*
|
|
284
|
+
* The payload runs to COMPLETION before this returns: the kernel never
|
|
285
|
+
* auto-backgrounds anything (plan constraint C-3, FM-054), so there is
|
|
286
|
+
* no daemonize path here to forget to guard.
|
|
287
|
+
*
|
|
288
|
+
* `async` since M4-P2, and its BODY IS UNCHANGED: every statement below
|
|
289
|
+
* is still synchronous, `spawnSync` is still what runs both children, and
|
|
290
|
+
* the promise this now returns is already settled by the time the first
|
|
291
|
+
* `await` on it runs. The keyword is there because the INTERFACE is async
|
|
292
|
+
* for the adapters that need it, not because this adapter gained a
|
|
293
|
+
* concurrency path to get wrong.
|
|
294
|
+
*
|
|
295
|
+
* It runs without a shell on purpose. Under a shell a missing payload
|
|
296
|
+
* binary arrives as an ordinary exit code 127, indistinguishable from a
|
|
297
|
+
* payload that ran and failed, and spawn's rollback rule turns on
|
|
298
|
+
* exactly that distinction.
|
|
299
|
+
*/
|
|
300
|
+
export const subprocessAdapter = {
|
|
301
|
+
/*
|
|
302
|
+
* THE NAME IS THE CONSTANT, not a second copy of the string (M4-P4
|
|
303
|
+
* criterion 5). The loader refuses a loaded adapter that claims this name,
|
|
304
|
+
* and a refusal compared against a literal spelled out in another file is
|
|
305
|
+
* a guard that goes quiet the day one of the two is edited.
|
|
306
|
+
*/
|
|
307
|
+
name: BUILT_IN_ADAPTER_NAME,
|
|
308
|
+
/*
|
|
309
|
+
* NOTHING, and that is a statement rather than a default (M4-P3). This
|
|
310
|
+
* adapter runs a command in a directory; it reads no brief, plays no role
|
|
311
|
+
* and asks for no tier, so declaring anything here would be a requirement
|
|
312
|
+
* the adapter does not have. The empty declaration is what makes the
|
|
313
|
+
* refusal path exercisable ONLY by an adapter that genuinely needs
|
|
314
|
+
* something, which is the state the check exists for.
|
|
315
|
+
*/
|
|
316
|
+
requires: [],
|
|
317
|
+
async launch(request) {
|
|
318
|
+
const launchedAt = new Date();
|
|
319
|
+
const record = {
|
|
320
|
+
adapter: BUILT_IN_ADAPTER_NAME,
|
|
321
|
+
launchedAt: launchedAt.toISOString(),
|
|
322
|
+
};
|
|
323
|
+
if (request.deadlineSeconds !== undefined) {
|
|
324
|
+
record.deadline = new Date(launchedAt.getTime() + request.deadlineSeconds * 1000).toISOString();
|
|
325
|
+
}
|
|
326
|
+
// VERBATIM (M4-P3 criterion 6): the value the caller supplied, byte for
|
|
327
|
+
// byte, with no normalisation, no lowercasing and no vocabulary check.
|
|
328
|
+
// The kernel holds no tier vocabulary and no role vocabulary, so there is
|
|
329
|
+
// nothing here it could legitimately validate against; a kernel that
|
|
330
|
+
// "tidied" either value would be holding one.
|
|
331
|
+
if (request.declaredTier !== undefined) {
|
|
332
|
+
record.requestedTier = request.declaredTier;
|
|
333
|
+
}
|
|
334
|
+
if (request.role !== undefined) {
|
|
335
|
+
record.requestedRole = request.role;
|
|
336
|
+
}
|
|
337
|
+
// The record write happens BEFORE the payload, so a failure here is
|
|
338
|
+
// provably a launch failure and is safe to roll back. Everything
|
|
339
|
+
// after the payload starts is reported as incomplete instead, which
|
|
340
|
+
// never rolls anything back (F-2's fix must not become V-1's defect).
|
|
341
|
+
const written = runStep(`writing the launch record ${request.recordPath}`, () => {
|
|
342
|
+
writeFileSync(request.recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
343
|
+
});
|
|
344
|
+
if (!written.ok) {
|
|
345
|
+
return { kind: "launch-failed", reason: written.reason };
|
|
346
|
+
}
|
|
347
|
+
const [program, ...args] = request.command;
|
|
348
|
+
if (program === undefined) {
|
|
349
|
+
return { kind: "launch-failed", reason: "empty --exec command" };
|
|
350
|
+
}
|
|
351
|
+
const result = spawnSync(program, args, {
|
|
352
|
+
cwd: request.worktree,
|
|
353
|
+
stdio: "inherit",
|
|
354
|
+
// The scrubbed environment (M2-P8). Spread rather than `env:
|
|
355
|
+
// request.env` so an undefined request.env means "no env option at
|
|
356
|
+
// all", which is Node's documented full-inheritance form.
|
|
357
|
+
...(request.env === undefined ? {} : { env: request.env }),
|
|
358
|
+
});
|
|
359
|
+
if (result.error !== undefined) {
|
|
360
|
+
return {
|
|
361
|
+
kind: "launch-failed",
|
|
362
|
+
reason: `cannot launch ${program}: ${String(result.error)}`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const exitCode = payloadExitCode(result.status, result.signal);
|
|
366
|
+
// The payload has run. Every failure below, raised or returned, is
|
|
367
|
+
// reported as incomplete: the worktree may hold real work now, so
|
|
368
|
+
// nothing here may lead to a rollback.
|
|
369
|
+
const hooked = runStep(`invoking the turn-end hook ${request.hookPath}`, () => spawnSync(process.execPath, [request.hookPath, String(exitCode)], {
|
|
370
|
+
stdio: "inherit",
|
|
371
|
+
// The hook child gets the SAME scrubbed environment as the
|
|
372
|
+
// payload (M2R-004 edit 4): a second launch nobody scrubbed is
|
|
373
|
+
// exactly the leak the finding names. The generated hook script
|
|
374
|
+
// itself reads no environment at all (src/hooks.ts), so the
|
|
375
|
+
// scrub cannot break it.
|
|
376
|
+
...(request.env === undefined ? {} : { env: request.env }),
|
|
377
|
+
}));
|
|
378
|
+
if (!hooked.ok) {
|
|
379
|
+
return {
|
|
380
|
+
kind: "incomplete",
|
|
381
|
+
reason: `the payload exited ${String(exitCode)} but the turn-end record could not ` +
|
|
382
|
+
`be written (${hooked.reason}); the worktree and the task directory are ` +
|
|
383
|
+
`left in place`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const hook = hooked.value;
|
|
387
|
+
if (hook.error !== undefined || hook.status !== 0) {
|
|
388
|
+
const detail = hook.error === undefined ? `exit ${String(hook.status)}` : String(hook.error);
|
|
389
|
+
return {
|
|
390
|
+
kind: "incomplete",
|
|
391
|
+
reason: `the payload exited ${String(exitCode)} but the turn-end hook ` +
|
|
392
|
+
`${request.hookPath} failed (${detail}); the worktree and the task ` +
|
|
393
|
+
`directory are left in place`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
// WHAT THIS ADAPTER ACTUALLY LAUNCHED WITH (M4-P8 criterion 6), read
|
|
397
|
+
// off `request.env` at the point of report rather than recomputed from
|
|
398
|
+
// the option object: the two spawnSync calls above spread that same
|
|
399
|
+
// value, so a mutation between the handover and the launch shows up
|
|
400
|
+
// here. Sorted so the comparison is over a set, not an insertion order.
|
|
401
|
+
return {
|
|
402
|
+
kind: "completed",
|
|
403
|
+
exitCode,
|
|
404
|
+
...(request.env === undefined
|
|
405
|
+
? {}
|
|
406
|
+
: {
|
|
407
|
+
launchedEnvNames: Object.keys(request.env).sort(),
|
|
408
|
+
// The five pointers as this adapter launched them, read off the
|
|
409
|
+
// same `request.env` the two spawnSync calls above spread
|
|
410
|
+
// (CR-B-001). The kernel treats this as the WEAKER of its two
|
|
411
|
+
// pointer sources and prefers the turn-end hook's child-written
|
|
412
|
+
// observation; reporting it anyway means an adapter that cannot
|
|
413
|
+
// run the hook still has something to be compared against.
|
|
414
|
+
launchedRedirections: Object.fromEntries(CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => [
|
|
415
|
+
redirection.name,
|
|
416
|
+
request.env?.[redirection.name] ?? null,
|
|
417
|
+
])),
|
|
418
|
+
}),
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
/**
|
|
423
|
+
* THE COMPLETION PRECONDITION (M4-P2 step 5).
|
|
424
|
+
*
|
|
425
|
+
* `completed` used to be believed because the only adapter that could
|
|
426
|
+
* return it was the one three lines above, which invokes the turn-end hook
|
|
427
|
+
* itself before returning. Once `launch` is a promise that is no longer
|
|
428
|
+
* true: any adapter may resolve `completed` while its agent is still
|
|
429
|
+
* running, and `spawnTask` would then delete the scrub root out from under
|
|
430
|
+
* a LIVE child's HOME and report success.
|
|
431
|
+
*
|
|
432
|
+
* So the kernel stops taking the adapter's word and reads the artifact the
|
|
433
|
+
* payload's own exit produces. tasks/<id>/turn-end is written by the
|
|
434
|
+
* generated hook (src/hooks.ts:57) with the payload's exit code; it is the
|
|
435
|
+
* same file the watcher wakes on. A `completed` with no readable turn-end
|
|
436
|
+
* record is refused.
|
|
437
|
+
*
|
|
438
|
+
* FOUR distinct refusals, not one, and the distinction is the point. A
|
|
439
|
+
* check written as `existsSync` is green on a present-but-corrupt record,
|
|
440
|
+
* which is a guard whose condition does not test the property that matters.
|
|
441
|
+
* Absent, unreadable, unparseable and wrongly-shaped are all "this is not
|
|
442
|
+
* evidence that the payload ended", and each says which one it was.
|
|
443
|
+
*
|
|
444
|
+
* The read goes through `readRegularFileIfPresent` rather than
|
|
445
|
+
* `readFileSync` so a FIFO at the turn-end path is a refusal and not a
|
|
446
|
+
* hang: this is the same hazard CR-520 records for meta.json, one path
|
|
447
|
+
* along.
|
|
448
|
+
*
|
|
449
|
+
* WHAT THIS DOES NOT DO: it never rolls anything back and it never removes
|
|
450
|
+
* anything. A refusal here is reported with the residue enumerated, exactly
|
|
451
|
+
* like the `incomplete` arm, because the payload demonstrably ran far
|
|
452
|
+
* enough for an adapter to claim it finished.
|
|
453
|
+
*/
|
|
454
|
+
function turnEndEvidence(fleet, taskId) {
|
|
455
|
+
const path = turnEndPath(fleet, taskId);
|
|
456
|
+
const read = readRegularFileIfPresent(path);
|
|
457
|
+
if (read.kind === "absent") {
|
|
458
|
+
return { ok: false, reason: `the turn-end record ${path} was never written` };
|
|
459
|
+
}
|
|
460
|
+
if (read.kind === "refused") {
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
reason: `the turn-end record ${path} could not be read (${read.reason})`,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
let parsed;
|
|
467
|
+
try {
|
|
468
|
+
parsed = JSON.parse(read.body);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
reason: `the turn-end record ${path} does not parse as JSON (${detail})`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
478
|
+
return {
|
|
479
|
+
ok: false,
|
|
480
|
+
reason: `the turn-end record ${path} does not parse as a turn-end record`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
const candidate = parsed;
|
|
484
|
+
if (typeof candidate.endedAt !== "string" || !Number.isInteger(candidate.exitCode)) {
|
|
485
|
+
return {
|
|
486
|
+
ok: false,
|
|
487
|
+
reason: `the turn-end record ${path} does not parse as a turn-end record ` +
|
|
488
|
+
`(it needs a string endedAt and an integer exitCode)`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// THE CHILD-WRITTEN POINTER OBSERVATION (CR-B-001). Absent on a record
|
|
492
|
+
// written by an older hook, and the ABSENCE IS NOT A PASS: the caller turns
|
|
493
|
+
// it into a weaker status word (`names-compared`) rather than into silence,
|
|
494
|
+
// which is the whole lesson of the finding one file over. A malformed or
|
|
495
|
+
// partially-typed `env` is read entry by entry and anything that is neither
|
|
496
|
+
// a string nor null is dropped, so a hostile record cannot smuggle an
|
|
497
|
+
// object into the comparison.
|
|
498
|
+
let observed;
|
|
499
|
+
if (typeof candidate.env === "object" && candidate.env !== null) {
|
|
500
|
+
observed = {};
|
|
501
|
+
for (const [name, value] of Object.entries(candidate.env)) {
|
|
502
|
+
if (typeof value === "string" || value === null) {
|
|
503
|
+
observed[name] = value;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return observed === undefined ? { ok: true } : { ok: true, observed };
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Liveness-guard seam (plan step 4). The guard itself is M1-P5 work; in
|
|
511
|
+
* this phase it is a documented no-op, so that P5 adds a body here
|
|
512
|
+
* rather than a new call site in the middle of the spawn sequence.
|
|
513
|
+
*/
|
|
514
|
+
export function livenessGuard(fleet) {
|
|
515
|
+
void fleet;
|
|
516
|
+
return { ok: true };
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Tokenise --exec. M1 supports a program plus plain arguments separated
|
|
520
|
+
* by whitespace; there is no shell and no quoting. Anything richer is an
|
|
521
|
+
* adapter's business (M4 era), not a string this kernel re-parses.
|
|
522
|
+
*/
|
|
523
|
+
export function parseExecCommand(command) {
|
|
524
|
+
return command.split(/\s+/u).filter((token) => token !== "");
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* One spawn. See the module docs for the rollback contract.
|
|
528
|
+
*
|
|
529
|
+
* --offline is passed straight through to pool create and is the only
|
|
530
|
+
* way a fleet can reach an offline base. meta.baseOffline is then COPIED
|
|
531
|
+
* from the pool record the create returned, never recomputed from this
|
|
532
|
+
* flag: pool create attempts the fetch even under --offline and records
|
|
533
|
+
* offline false when it succeeds, so a spawn that trusted its own flag
|
|
534
|
+
* would mark a perfectly fetched base as offline (PR-212, and the
|
|
535
|
+
* provenance inversion measured as V-2 in M1-P3).
|
|
536
|
+
*/
|
|
537
|
+
export async function spawnTask(fleet, options) {
|
|
538
|
+
const { taskId } = options;
|
|
539
|
+
// THE CREDENTIAL POLICY IS CHECKED BEFORE THE ADAPTER IS EVEN RESOLVED
|
|
540
|
+
// (M4-P8 step 5). Adapter selection reads the fleet home and may EVALUATE
|
|
541
|
+
// a module the kernel did not write (M4-P4), so a spawn whose credential
|
|
542
|
+
// shape is already refused must not get that far: the refusal creates
|
|
543
|
+
// nothing, loads nothing and runs nothing.
|
|
544
|
+
const credentials = checkCredentialPolicy(options);
|
|
545
|
+
if (!credentials.ok) {
|
|
546
|
+
return { ok: false, reason: credentials.reason };
|
|
547
|
+
}
|
|
548
|
+
// THE ADAPTER IS RESOLVED FIRST (M4-P3), earlier than it used to be, and
|
|
549
|
+
// the move is the point rather than a tidy-up: both checks below must
|
|
550
|
+
// refuse before ANYTHING is created, and until this phase the adapter was
|
|
551
|
+
// not named until the launch call site, which is after pool create has made
|
|
552
|
+
// a worktree, a branch and a pool record.
|
|
553
|
+
//
|
|
554
|
+
// M4-P4 PUTS THE LOAD IN THAT SAME WINDOW, and for the same reason one
|
|
555
|
+
// level out. Resolving, evaluating and shape-checking a module the kernel
|
|
556
|
+
// did not write are three more ways to discover that this spawn cannot
|
|
557
|
+
// happen, and every one of them must happen while there is still nothing to
|
|
558
|
+
// roll back: a malformed adapter found after pool create is criterion 4's
|
|
559
|
+
// dangerous state, which is M4-P3's refusal-after-creation shape with a
|
|
560
|
+
// different cause.
|
|
561
|
+
//
|
|
562
|
+
// A DIRECTLY SUPPLIED ADAPTER SHORT-CIRCUITS THE WHOLE SELECTION, including
|
|
563
|
+
// the fleet-home read. The kernel's own tests hand an object across this
|
|
564
|
+
// seam, and a test fleet that happened to declare a default would otherwise
|
|
565
|
+
// silently change which adapter those tests exercised.
|
|
566
|
+
let adapter;
|
|
567
|
+
if (options.adapter !== undefined) {
|
|
568
|
+
adapter = options.adapter;
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
const selection = await selectAdapter(fleet, options.adapterSpecifier);
|
|
572
|
+
if (!selection.ok) {
|
|
573
|
+
return { ok: false, reason: selection.reason };
|
|
574
|
+
}
|
|
575
|
+
adapter = selection.adapter ?? subprocessAdapter;
|
|
576
|
+
}
|
|
577
|
+
// A requirement naming a field the contract does not have is a defect in
|
|
578
|
+
// the adapter, refused as the adapter is taken up (criterion 3). It runs
|
|
579
|
+
// BEFORE the presence check because an unknown name is also an absent one,
|
|
580
|
+
// and answering a contract defect with a message about a missing value
|
|
581
|
+
// sends the operator looking for a flag that cannot exist.
|
|
582
|
+
const contract = checkAdapterContract(adapter);
|
|
583
|
+
if (!contract.ok) {
|
|
584
|
+
return { ok: false, reason: contract.reason };
|
|
585
|
+
}
|
|
586
|
+
// A declared requirement the spawn cannot meet is a usage error, and a
|
|
587
|
+
// usage error creates nothing (criterion 2).
|
|
588
|
+
const requirements = checkAdapterRequirements(adapter, options);
|
|
589
|
+
if (!requirements.ok) {
|
|
590
|
+
return { ok: false, reason: requirements.reason };
|
|
591
|
+
}
|
|
592
|
+
const liveness = livenessGuard(fleet);
|
|
593
|
+
if (!liveness.ok) {
|
|
594
|
+
return { ok: false, reason: liveness.reason };
|
|
595
|
+
}
|
|
596
|
+
const holdership = checkHoldership(fleet);
|
|
597
|
+
if (!holdership.ok) {
|
|
598
|
+
return { ok: false, reason: holdership.reason };
|
|
599
|
+
}
|
|
600
|
+
/* THE CROSS-ENVIRONMENT HALF OF THE SAME GUARD (M4-P22 criterion 2), and
|
|
601
|
+
it runs here rather than inside `checkHoldership` because the two answer
|
|
602
|
+
different questions and one of them spawns git. `checkHoldership` above
|
|
603
|
+
has ALREADY RETURNED OK in the state this refuses: the local lease is
|
|
604
|
+
held by this environment, with TIPHYS_HOLDER_ID matching, while the
|
|
605
|
+
shared register names another environment. That is precisely M4-P20's
|
|
606
|
+
measured dangerous state, two clones of one fleet remote each holding a
|
|
607
|
+
live lease, and the local check has no evidence of it because `state/`
|
|
608
|
+
is gitignored so the lease artifact never travels (src/fleet.ts:29).
|
|
609
|
+
|
|
610
|
+
IT IS BEFORE pool create, before the task directory, before the brief and
|
|
611
|
+
before any executor, so the refusal creates nothing, which is the same
|
|
612
|
+
ordering rule the adapter and id-reuse refusals above follow. A fleet
|
|
613
|
+
with no declaration gets `off` before any subprocess is spawned. */
|
|
614
|
+
const sharedGuard = guardSharedRegister(fleet.root, "spawn");
|
|
615
|
+
if (sharedGuard.kind === "refused") {
|
|
616
|
+
return { ok: false, reason: sharedGuard.reason };
|
|
617
|
+
}
|
|
618
|
+
const command = parseExecCommand(options.exec);
|
|
619
|
+
if (command.length === 0) {
|
|
620
|
+
return { ok: false, reason: "--exec is empty" };
|
|
621
|
+
}
|
|
622
|
+
// CR-301, checked before pool create so the refusal creates nothing and
|
|
623
|
+
// destroys nothing. tasks/<id>/ survives every teardown by design, so a
|
|
624
|
+
// reused id would otherwise overwrite a closed task's records, hand the
|
|
625
|
+
// rollback files it did not create, and leave the previous
|
|
626
|
+
// incarnation's turn-end readable beside a meta that says open.
|
|
627
|
+
if (taskDirOccupied(fleet, taskId)) {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
reason: `task directory ${taskDir(fleet, taskId)} already holds records for task ` +
|
|
631
|
+
`id ${taskId}; a task id is spawned once, so choose a fresh id or move ` +
|
|
632
|
+
`that directory aside before re-using this one`,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const created = await poolCreate(fleet, {
|
|
636
|
+
taskId,
|
|
637
|
+
project: options.project,
|
|
638
|
+
offline: options.offline,
|
|
639
|
+
});
|
|
640
|
+
if (!created.ok) {
|
|
641
|
+
return { ok: false, reason: created.reason };
|
|
642
|
+
}
|
|
643
|
+
const poolRecord = created.value;
|
|
644
|
+
const worktree = worktreePath(fleet, taskId);
|
|
645
|
+
// Everything below is rollback-scoped: this list holds exactly what
|
|
646
|
+
// THIS invocation created, in creation order.
|
|
647
|
+
const createdFiles = [];
|
|
648
|
+
const dir = taskDir(fleet, taskId);
|
|
649
|
+
const createdTaskDir = !taskDirExists(fleet, taskId);
|
|
650
|
+
const rollback = async (reason) => {
|
|
651
|
+
for (const file of [...createdFiles].reverse()) {
|
|
652
|
+
try {
|
|
653
|
+
unlinkSync(file);
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// Never written, or already gone.
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// The scrub root (harness-owned redirect targets, M2-P8) is created
|
|
660
|
+
// by THIS invocation strictly before the launch, and this rollback
|
|
661
|
+
// only ever runs before the payload has started, so removing it
|
|
662
|
+
// recursively removes only what this invocation staged. It sits
|
|
663
|
+
// inside the task directory, never inside the worktree.
|
|
664
|
+
try {
|
|
665
|
+
rmSync(scrubRoot(dir), { recursive: true, force: true });
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
// Never created, or already gone.
|
|
669
|
+
}
|
|
670
|
+
if (createdTaskDir) {
|
|
671
|
+
try {
|
|
672
|
+
rmdirSync(dir);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
// Left in place when it is not empty: whatever is in it was not
|
|
676
|
+
// created by this invocation, and this rollback does not own it.
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const destroyed = await poolDestroy(fleet, {
|
|
680
|
+
taskId,
|
|
681
|
+
discard: false,
|
|
682
|
+
deleteBranchForce: false,
|
|
683
|
+
});
|
|
684
|
+
if (!destroyed.ok) {
|
|
685
|
+
return {
|
|
686
|
+
ok: false,
|
|
687
|
+
reason: `${reason}; rollback of the worktree did not complete: ${destroyed.reason}`,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
return { ok: false, reason };
|
|
691
|
+
};
|
|
692
|
+
// From here to the launch, EVERY step goes through runStep: a raised
|
|
693
|
+
// fs error is folded into the same ok/reason shape a returned failure
|
|
694
|
+
// uses, so one handler covers both and the rollback cannot be walked
|
|
695
|
+
// past (F-2).
|
|
696
|
+
if (createdTaskDir) {
|
|
697
|
+
const made = runStep(`creating the task directory ${dir}`, () => {
|
|
698
|
+
mkdirSync(dir, { recursive: true });
|
|
699
|
+
});
|
|
700
|
+
if (!made.ok) {
|
|
701
|
+
return rollback(made.reason);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const brief = runStep(`assembling the brief for task ${taskId}`, () => assembleBrief(fleet, taskId, options.briefFile));
|
|
705
|
+
if (!brief.ok) {
|
|
706
|
+
return rollback(brief.reason);
|
|
707
|
+
}
|
|
708
|
+
if (!brief.value.ok) {
|
|
709
|
+
return rollback(brief.value.reason);
|
|
710
|
+
}
|
|
711
|
+
const briefPath = brief.value.value;
|
|
712
|
+
createdFiles.push(briefPath);
|
|
713
|
+
// THE CREDENTIAL DECISION, WRITTEN DOWN (M4-P8 step 6). The hazard this
|
|
714
|
+
// phase names is a credential reaching a project payload with NO ARTIFACT
|
|
715
|
+
// SAYING SO, so the record is written on every spawn, including the
|
|
716
|
+
// boring one where nothing was widened and the scrub ran. The extensions
|
|
717
|
+
// are copied verbatim, reason and all: a widening whose justification
|
|
718
|
+
// exists only in the caller's source is not auditable from the task
|
|
719
|
+
// directory an operator opens.
|
|
720
|
+
const credentialRecord = {
|
|
721
|
+
payloadClass: options.payloadClass,
|
|
722
|
+
scrubMode: options.allowPrCredentials === true ? "inherited" : "scrubbed",
|
|
723
|
+
// BUILT THROUGH THE ACCESSORS (CR-B-002, the record half). Reading
|
|
724
|
+
// `entry.name` directly produced the literal record `{}` for a
|
|
725
|
+
// bare-string entry, so meta.json said a widening happened and not WHICH
|
|
726
|
+
// name was widened. `extensionReason` returning undefined is recorded as
|
|
727
|
+
// an ABSENT key rather than defaulted to "", because a blank reason and a
|
|
728
|
+
// missing one are different facts and the audited route refuses both.
|
|
729
|
+
extensions: (options.extraAllowlist ?? []).map((entry) => {
|
|
730
|
+
const reason = extensionReason(entry);
|
|
731
|
+
return {
|
|
732
|
+
name: extensionName(entry),
|
|
733
|
+
...(typeof reason === "string" ? { reason } : {}),
|
|
734
|
+
};
|
|
735
|
+
}),
|
|
736
|
+
};
|
|
737
|
+
const meta = {
|
|
738
|
+
id: taskId,
|
|
739
|
+
project: poolRecord.project,
|
|
740
|
+
shape: options.shape,
|
|
741
|
+
branch: poolRecord.branchName,
|
|
742
|
+
worktree,
|
|
743
|
+
baseSha: poolRecord.baseSha,
|
|
744
|
+
baseOffline: poolRecord.offline,
|
|
745
|
+
status: "open",
|
|
746
|
+
createdAt: new Date().toISOString(),
|
|
747
|
+
credentials: credentialRecord,
|
|
748
|
+
};
|
|
749
|
+
const wroteMeta = runStep(`writing ${metaPath(fleet, taskId)}`, () => {
|
|
750
|
+
writeTaskMeta(fleet, meta);
|
|
751
|
+
});
|
|
752
|
+
if (!wroteMeta.ok) {
|
|
753
|
+
return rollback(wroteMeta.reason);
|
|
754
|
+
}
|
|
755
|
+
createdFiles.push(metaPath(fleet, taskId));
|
|
756
|
+
// THE HOOK RECORDS THE FIVE POINTERS FROM INSIDE THE CHILD (CR-B-001).
|
|
757
|
+
// Passed on every spawn, including under the declared escape hatch, where
|
|
758
|
+
// the kernel handed no environment over and so compares nothing: a hook
|
|
759
|
+
// whose shape depended on the escape hatch would be one more thing that
|
|
760
|
+
// differs between the two arms, and T-009's lesson is that the arm nobody
|
|
761
|
+
// witnesses is the one that breaks.
|
|
762
|
+
const hook = runStep(`writing the turn-end hook for task ${taskId}`, () => writeTurnEndHook(fleet, taskId, CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => redirection.name)));
|
|
763
|
+
if (!hook.ok) {
|
|
764
|
+
return rollback(hook.reason);
|
|
765
|
+
}
|
|
766
|
+
const hookPath = hook.value;
|
|
767
|
+
createdFiles.push(hookPath);
|
|
768
|
+
const recordPath = executorRecordPath(fleet, taskId);
|
|
769
|
+
createdFiles.push(recordPath);
|
|
770
|
+
// The child environment (M2-P8): built from the allowlist with the
|
|
771
|
+
// credential-store pointers redirected into this task's directory,
|
|
772
|
+
// unless the caller passed the declared escape hatch. Built BEFORE the
|
|
773
|
+
// launch so a staging failure is a rollback, never a half-scrubbed
|
|
774
|
+
// child.
|
|
775
|
+
let childEnv;
|
|
776
|
+
if (options.allowPrCredentials !== true) {
|
|
777
|
+
const built = runStep(`constructing the scrubbed child environment for task ${taskId}`, () => buildChildEnv({
|
|
778
|
+
parentEnv: process.env,
|
|
779
|
+
scrubDir: scrubRoot(dir),
|
|
780
|
+
// M4-P8 step 3: the per-invocation extension finally has a way to
|
|
781
|
+
// reach this call. Before this phase `extraAllowlist` existed as
|
|
782
|
+
// data and NOTHING passed one, so the field was unreachable from
|
|
783
|
+
// every production path.
|
|
784
|
+
extraAllowlist: options.extraAllowlist ?? [],
|
|
785
|
+
}));
|
|
786
|
+
if (!built.ok) {
|
|
787
|
+
return rollback(built.reason);
|
|
788
|
+
}
|
|
789
|
+
if (!built.value.ok) {
|
|
790
|
+
return rollback(built.value.reason);
|
|
791
|
+
}
|
|
792
|
+
childEnv = built.value.env;
|
|
793
|
+
}
|
|
794
|
+
// AWAITED (M4-P2 step 4), and `runStepAsync` rather than `runStep` is
|
|
795
|
+
// load-bearing rather than cosmetic. `runStep` over a promise-returning
|
|
796
|
+
// callback returns {ok: true, value: <a pending promise>} before the
|
|
797
|
+
// adapter has done anything: the launch-failed arm below would never be
|
|
798
|
+
// reached, `outcome.kind` would be undefined on every launch, and a
|
|
799
|
+
// rejection would leave the result type entirely as an unhandled
|
|
800
|
+
// rejection with no handler to roll back or refuse.
|
|
801
|
+
const launched = await runStepAsync(`launching the payload through the ${adapter.name} adapter`, async () => adapter.launch({
|
|
802
|
+
taskId,
|
|
803
|
+
worktree,
|
|
804
|
+
command,
|
|
805
|
+
hookPath,
|
|
806
|
+
recordPath,
|
|
807
|
+
deadlineSeconds: options.deadlineSeconds,
|
|
808
|
+
env: childEnv,
|
|
809
|
+
// THE BRIEF THE ADAPTER LAUNCHES AGAINST (M4-P3 criterion 1). The
|
|
810
|
+
// path `assembleBrief` returned, which is already on `createdFiles`
|
|
811
|
+
// and so is already inside the rollback window.
|
|
812
|
+
briefPath,
|
|
813
|
+
role: options.role,
|
|
814
|
+
declaredTier: options.declaredTier,
|
|
815
|
+
phaseId: options.phaseId,
|
|
816
|
+
}));
|
|
817
|
+
if (!launched.ok) {
|
|
818
|
+
// An adapter that THREW rather than returning an outcome cannot tell
|
|
819
|
+
// us whether the payload started, and this rollback destroys a
|
|
820
|
+
// worktree. Refusing to guess is the whole lesson of V-1: the state
|
|
821
|
+
// is left in place and enumerated instead.
|
|
822
|
+
//
|
|
823
|
+
// Since M4-P2 this arm also covers a REJECTED promise, and it covers
|
|
824
|
+
// it for the same reason and with the same words: a rejection is an
|
|
825
|
+
// adapter failing to report, and WHEN it rejects tells us nothing,
|
|
826
|
+
// because an adapter that rejects before the payload starts and one
|
|
827
|
+
// that rejects after it dies are indistinguishable from here. An
|
|
828
|
+
// adapter that actually knows the payload never started says so, by
|
|
829
|
+
// RETURNING launch-failed, and that arm rolls back.
|
|
830
|
+
return {
|
|
831
|
+
ok: false,
|
|
832
|
+
reason: `${launched.reason}; the ${adapter.name} adapter did not report whether the ` +
|
|
833
|
+
`payload started, so nothing was rolled back: the worktree ${worktree}, its ` +
|
|
834
|
+
`task directory and the pool record are left in place for inspection; when ` +
|
|
835
|
+
`you have inspected them, close the task with "tiphys teardown --task ${taskId}"`,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
const outcome = launched.value;
|
|
839
|
+
if (outcome.kind === "launch-failed") {
|
|
840
|
+
return rollback(`executor launch failed: ${outcome.reason}`);
|
|
841
|
+
}
|
|
842
|
+
// FROM HERE THE PAYLOAD HAS RUN, ON BOTH REMAINING ARMS (CR-B-003).
|
|
843
|
+
//
|
|
844
|
+
// `launch-failed` returned above and is the only arm where nothing ran.
|
|
845
|
+
// `completed` and `incomplete` both mean a child was launched with an
|
|
846
|
+
// environment, so "which environment did it actually get" is a real
|
|
847
|
+
// question on both, and until this round it was asked on neither the
|
|
848
|
+
// `incomplete` arm nor the arm where the completion precondition fails:
|
|
849
|
+
// the comparison sat below all three returns and `meta.json` carried no
|
|
850
|
+
// `handover` key at all. A widening on those arms was recorded NOWHERE
|
|
851
|
+
// while the same widening on the `completed` arm was recorded and refused.
|
|
852
|
+
// So the comparison moves up to here, ahead of every arm-specific return,
|
|
853
|
+
// and `meta.json` is rewritten before any of them.
|
|
854
|
+
//
|
|
855
|
+
// The turn-end record is read first because it carries the child-written
|
|
856
|
+
// pointer observation, and it is read on the `incomplete` arm too: an
|
|
857
|
+
// adapter that reports incomplete because the HOOK failed leaves no record
|
|
858
|
+
// and the read simply finds nothing, which is a weaker status word and not
|
|
859
|
+
// a pass.
|
|
860
|
+
const evidence = turnEndEvidence(fleet, taskId);
|
|
861
|
+
const pointers = (() => {
|
|
862
|
+
if (childEnv === undefined) {
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
if (evidence.ok && evidence.observed !== undefined) {
|
|
866
|
+
return { source: "turn-end-record", values: evidence.observed };
|
|
867
|
+
}
|
|
868
|
+
if (outcome.launchedRedirections !== undefined) {
|
|
869
|
+
return { source: "adapter", values: outcome.launchedRedirections };
|
|
870
|
+
}
|
|
871
|
+
return undefined;
|
|
872
|
+
})();
|
|
873
|
+
// THE HANDOVER CHECK (M4-P8 criterion 6, repaired by CR-B-001).
|
|
874
|
+
// src/spawn.ts's request contract has always FORBIDDEN an adapter widening
|
|
875
|
+
// `env` on its own, in prose, and nothing checked it. The adapter reports
|
|
876
|
+
// the name set it launched with and the kernel compares it; SINCE THIS
|
|
877
|
+
// ROUND the kernel also compares the five credential-store pointers, which
|
|
878
|
+
// is the half a name-set comparison structurally cannot see, because the
|
|
879
|
+
// M2R-004 defense works by redirecting those names rather than by dropping
|
|
880
|
+
// them.
|
|
881
|
+
//
|
|
882
|
+
// IT RUNS AFTER THE PAYLOAD HAS RUN, so it never rolls anything back: the
|
|
883
|
+
// worktree may hold real work by now (the V-1 rule), and the refusal's
|
|
884
|
+
// job is to make the widening impossible to miss, not to destroy
|
|
885
|
+
// evidence of it. The comparison is recorded in meta.json whichever way
|
|
886
|
+
// it goes, so a clean handover is an artifact too.
|
|
887
|
+
const handover = compareHandover(childEnv, outcome.launchedEnvNames, pointers);
|
|
888
|
+
credentialRecord.handover = handover;
|
|
889
|
+
const widened = handover.added.length > 0 ||
|
|
890
|
+
handover.removed.length > 0 ||
|
|
891
|
+
handover.changedRedirections.length > 0;
|
|
892
|
+
if (widened) {
|
|
893
|
+
credentialRecord.refusal =
|
|
894
|
+
`the ${adapter.name} adapter launched with an environment that differs ` +
|
|
895
|
+
`from the one the kernel handed it` +
|
|
896
|
+
(handover.added.length === 0 ? "" : `; added ${handover.added.join(", ")}`) +
|
|
897
|
+
(handover.removed.length === 0 ? "" : `; removed ${handover.removed.join(", ")}`) +
|
|
898
|
+
(handover.changedRedirections.length === 0
|
|
899
|
+
? ""
|
|
900
|
+
: `; the credential-store pointer(s) ` +
|
|
901
|
+
`${handover.changedRedirections.join(", ")} did not have the ` +
|
|
902
|
+
`harness-owned value the kernel handed over, ` +
|
|
903
|
+
`${redirectionSourcePhrase(handover.redirectionSource)}`);
|
|
904
|
+
}
|
|
905
|
+
const rewroteMeta = runStep(`updating ${metaPath(fleet, taskId)}`, () => {
|
|
906
|
+
writeTaskMeta(fleet, meta);
|
|
907
|
+
});
|
|
908
|
+
if (!rewroteMeta.ok) {
|
|
909
|
+
// The payload ran, so nothing is rolled back here either; the record
|
|
910
|
+
// simply could not be completed and says so rather than being silently
|
|
911
|
+
// left at its pre-launch contents.
|
|
912
|
+
return {
|
|
913
|
+
ok: false,
|
|
914
|
+
reason: `the payload ran (the ${adapter.name} adapter reported ${outcome.kind}) ` +
|
|
915
|
+
`but the credential record could not be completed ` +
|
|
916
|
+
`(${rewroteMeta.reason}); the worktree ${worktree}, its task directory ` +
|
|
917
|
+
`and the pool record are left in place`,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
if (outcome.kind === "incomplete") {
|
|
921
|
+
// The payload ran, so nothing is rolled back, and the reason says so.
|
|
922
|
+
// The scrub root is deliberately LEFT in place here: the hook child
|
|
923
|
+
// failed, and whatever the children left under the redirected paths
|
|
924
|
+
// is part of the state an operator inspects. The handover verdict is
|
|
925
|
+
// now part of the reason as well as of the record, because this arm's
|
|
926
|
+
// reason is the only thing many callers read.
|
|
927
|
+
return {
|
|
928
|
+
ok: false,
|
|
929
|
+
reason: widened
|
|
930
|
+
? `${outcome.reason}; and ${credentialRecord.refusal ?? ""}, recorded in ` +
|
|
931
|
+
`${metaPath(fleet, taskId)}`
|
|
932
|
+
: outcome.reason,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
// THE COMPLETION PRECONDITION (M4-P2 step 5). The only arm left is
|
|
936
|
+
// `completed`, and it is the only arm that DESTROYS something (the
|
|
937
|
+
// scrub root, which is a live child's redirected HOME while that child
|
|
938
|
+
// lives). Before M4-P2 the destruction was safe because `launch` was
|
|
939
|
+
// synchronous and the sole adapter wrote the turn-end record itself; an
|
|
940
|
+
// async `launch` lets any adapter resolve `completed` early, so the
|
|
941
|
+
// kernel checks the payload's own artifact instead of believing the
|
|
942
|
+
// report. See turnEndEvidence above for the four refusals.
|
|
943
|
+
//
|
|
944
|
+
// THE READ ITSELF MOVED UP (CR-B-003): `evidence` is computed before the
|
|
945
|
+
// handover comparison, because the turn-end record is where the child's
|
|
946
|
+
// own pointer observation lives. Only the REFUSAL is here, so this arm
|
|
947
|
+
// still refuses exactly what it refused, and it now does so with the
|
|
948
|
+
// handover already written down.
|
|
949
|
+
if (!evidence.ok) {
|
|
950
|
+
// The scrub root is named only when there IS one. Under the declared
|
|
951
|
+
// escape hatch childEnv is undefined and nothing was ever staged under
|
|
952
|
+
// scrubRoot(dir), so naming it would enumerate a path that does not
|
|
953
|
+
// exist, in the one message an operator uses to find the residue.
|
|
954
|
+
const residue = childEnv === undefined
|
|
955
|
+
? `the worktree ${worktree}, its task directory and the pool record are all`
|
|
956
|
+
: `the worktree ${worktree}, its task directory, the pool record and the ` +
|
|
957
|
+
`harness-owned redirect targets under ${scrubRoot(dir)} are all`;
|
|
958
|
+
return {
|
|
959
|
+
ok: false,
|
|
960
|
+
reason: `the ${adapter.name} adapter reported the payload completed with exit code ` +
|
|
961
|
+
`${String(outcome.exitCode)}, but ${evidence.reason}, so the kernel does not ` +
|
|
962
|
+
`accept that the payload ended; nothing was rolled back and nothing was ` +
|
|
963
|
+
`removed: ${residue} left in place for inspection; when you have inspected ` +
|
|
964
|
+
`them, close the task with "tiphys teardown --task ${taskId}"` +
|
|
965
|
+
// THE WIDENING IS NAMED ON THIS ARM TOO (CR-B-003). The record already
|
|
966
|
+
// carries it, and this reason is the only thing many callers read, so
|
|
967
|
+
// leaving it out here would make the arm's refusal say less than the
|
|
968
|
+
// artifact beside it.
|
|
969
|
+
(widened
|
|
970
|
+
? `; and ${credentialRecord.refusal ?? ""}, recorded in ${metaPath(fleet, taskId)}`
|
|
971
|
+
: ""),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
if (widened) {
|
|
975
|
+
return {
|
|
976
|
+
ok: false,
|
|
977
|
+
reason: `${credentialRecord.refusal ?? ""}; an adapter never widens the child ` +
|
|
978
|
+
`environment it was given (see ExecutorRequest.env), and the difference ` +
|
|
979
|
+
`is recorded in ${metaPath(fleet, taskId)}; nothing was rolled back ` +
|
|
980
|
+
`because the payload had already run`,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
// The scrub root is ephemeral. Both children have exited: the turn-end
|
|
984
|
+
// record exists and parses, which is the payload's own exit writing
|
|
985
|
+
// itself down, and the launch promise has been awaited, so the harness-
|
|
986
|
+
// owned redirect targets have no further reader. That sentence used to
|
|
987
|
+
// read "the launch is synchronous, C-3", and it stopped being true the
|
|
988
|
+
// moment `launch` returned a promise; a comment asserting a dead
|
|
989
|
+
// invariant is how the next reader re-derives the defect, so the
|
|
990
|
+
// reasoning is restated rather than left. C-3 is still satisfied, by
|
|
991
|
+
// the await rather than by the signature. This removal touches ONLY the
|
|
992
|
+
// scrub root, never the worktree, so it cannot be a V-1-shaped loss.
|
|
993
|
+
if (childEnv !== undefined) {
|
|
994
|
+
try {
|
|
995
|
+
rmSync(scrubRoot(dir), { recursive: true, force: true });
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
// A leftover empty scrub directory is benign; failing a completed
|
|
999
|
+
// spawn over its cleanup would not be.
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return { ok: true, value: { meta, exitCode: outcome.exitCode } };
|
|
1003
|
+
}
|