@tiphys/kernel 0.0.0 → 0.1.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 +611 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +278 -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 +14 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +59 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +828 -0
- package/dist/src/checks.js +3314 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +57 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +320 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/doctor.d.ts +35 -0
- package/dist/src/commands/doctor.js +605 -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 +128 -0
- package/dist/src/commands/lock.d.ts +1 -0
- package/dist/src/commands/lock.js +229 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -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 +128 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +146 -0
- package/dist/src/commands/status.d.ts +19 -0
- package/dist/src/commands/status.js +120 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +79 -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 +360 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/exec/env.d.ts +128 -0
- package/dist/src/exec/env.js +190 -0
- package/dist/src/fleet.d.ts +51 -0
- package/dist/src/fleet.js +80 -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 +1163 -0
- package/dist/src/gates/coverage.d.ts +284 -0
- package/dist/src/gates/coverage.js +701 -0
- package/dist/src/gates/credentials.d.ts +74 -0
- package/dist/src/gates/credentials.js +533 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -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 +390 -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 +566 -0
- package/dist/src/gates/run.js +1536 -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 +42 -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 +927 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +32 -0
- package/dist/src/hooks.js +62 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +178 -0
- package/dist/src/lock.js +500 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +2 -0
- package/dist/src/path-identity.js +10 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +130 -0
- package/dist/src/pool.js +721 -0
- package/dist/src/roles.d.ts +430 -0
- package/dist/src/roles.js +734 -0
- package/dist/src/spawn.d.ts +177 -0
- package/dist/src/spawn.js +332 -0
- package/dist/src/status.d.ts +91 -0
- package/dist/src/status.js +119 -0
- package/dist/src/task.d.ts +264 -0
- package/dist/src/task.js +305 -0
- package/dist/src/teardown.d.ts +32 -0
- package/dist/src/teardown.js +314 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +230 -0
- package/dist/src/validate.js +732 -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 +859 -0
- package/dist/src/witness/run.d.ts +274 -0
- package/dist/src/witness/run.js +1327 -0
- package/dist/src/witness/spec.d.ts +102 -0
- package/dist/src/witness/spec.js +253 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +390 -0
- package/gates.manifest.json +195 -0
- package/package.json +57 -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 +460 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +81 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +166 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/decision-record.schema.json +88 -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/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 +289 -0
- package/schemas/work-history.schema.json +183 -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,177 @@
|
|
|
1
|
+
import type { Fleet } from "./fleet.ts";
|
|
2
|
+
import type { GuardResult, TaskMeta, TaskShape } from "./task.ts";
|
|
3
|
+
/**
|
|
4
|
+
* tiphys spawn (kernel plan v1, M1-P4 step 4): worktree, brief, turn-end
|
|
5
|
+
* hook, task meta and the executor launch in ONE command.
|
|
6
|
+
*
|
|
7
|
+
* Order, exactly as the plan states it: liveness-guard check (M1-P5; a
|
|
8
|
+
* documented no-op seam in this phase), holdership check, pool create,
|
|
9
|
+
* brief assembly, meta write, executor launch. The holdership check runs
|
|
10
|
+
* before pool create because its refusal must create nothing
|
|
11
|
+
* (criterion 12).
|
|
12
|
+
*
|
|
13
|
+
* Rollback contract, stated narrowly because the broad version would not
|
|
14
|
+
* be honest (the lesson M1-P3's destroy paid for four times over):
|
|
15
|
+
*
|
|
16
|
+
* - A failure BEFORE pool create removes nothing, because nothing was
|
|
17
|
+
* created. In particular a duplicate task id leaves any pre-existing
|
|
18
|
+
* tasks/<id>/ byte-identical: this command writes nothing under
|
|
19
|
+
* tasks/ until pool create has succeeded (criterion 4, PR-005).
|
|
20
|
+
* - A failure AFTER pool create and BEFORE the payload starts removes
|
|
21
|
+
* exactly what this invocation created (the files it wrote, the task
|
|
22
|
+
* directory when it created it, and the pool worktree) and nothing
|
|
23
|
+
* else (criterion 5). This holds for a step that RETURNS a failure
|
|
24
|
+
* and for one that THROWS: every write in that window goes through
|
|
25
|
+
* runStep, because an unwrapped raise walked past the rollback
|
|
26
|
+
* entirely and orphaned the worktree, the branch and the pool record
|
|
27
|
+
* (F-2). The worktree is untouched at that point, so
|
|
28
|
+
* the removal passes the ordinary pool destroy gates and needs no
|
|
29
|
+
* force flag: --discard and --delete-branch-force are deliberately
|
|
30
|
+
* NOT passed, so a worktree that somehow is not pristine refuses and
|
|
31
|
+
* survives rather than being destroyed by a cleanup path.
|
|
32
|
+
* - Once the PAYLOAD HAS STARTED, nothing is ever rolled back. The
|
|
33
|
+
* worktree may hold real work by then, and a cleanup that removed it
|
|
34
|
+
* would be M1-P3's V-1 data-loss defect with a different name. A
|
|
35
|
+
* failure after that point reports what happened and changes
|
|
36
|
+
* nothing.
|
|
37
|
+
*
|
|
38
|
+
* Substrate: everything here is substrate-neutral except the executor,
|
|
39
|
+
* which sits behind ExecutorAdapter (DR-0007). M1 ships exactly one
|
|
40
|
+
* adapter, the local subprocess one.
|
|
41
|
+
*/
|
|
42
|
+
/** What the adapter is asked to do. Everything crosses as files and exit codes. */
|
|
43
|
+
export interface ExecutorRequest {
|
|
44
|
+
taskId: string;
|
|
45
|
+
/** cwd for the payload: the task worktree. */
|
|
46
|
+
worktree: string;
|
|
47
|
+
/** argv of the payload, argv[0] being the program. */
|
|
48
|
+
command: string[];
|
|
49
|
+
/** Script the adapter invokes with the payload exit code when it exits. */
|
|
50
|
+
hookPath: string;
|
|
51
|
+
/** Where the adapter writes its launch record. */
|
|
52
|
+
recordPath: string;
|
|
53
|
+
/** Optional non-completion deadline in seconds (PR-207). */
|
|
54
|
+
deadlineSeconds: number | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* The EXACT environment for BOTH children this launch produces: the
|
|
57
|
+
* payload and the turn-end hook (M2-P8, M2R-004 edit 4). Built by
|
|
58
|
+
* `buildChildEnv` (src/exec/env.ts): allowlisted names only, with the
|
|
59
|
+
* five credential-store pointers redirected to harness-owned paths.
|
|
60
|
+
* `undefined` means the children inherit the parent's environment
|
|
61
|
+
* UNCHANGED, and is only ever passed under `allowPrCredentials`, the
|
|
62
|
+
* declared escape hatch; an adapter must never widen it on its own.
|
|
63
|
+
*/
|
|
64
|
+
env: Record<string, string> | undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Launch outcomes. The distinction between a payload that never started
|
|
68
|
+
* and one that did is load-bearing: only the first authorizes rollback.
|
|
69
|
+
*/
|
|
70
|
+
export type LaunchOutcome = {
|
|
71
|
+
kind: "completed";
|
|
72
|
+
exitCode: number;
|
|
73
|
+
} | {
|
|
74
|
+
kind: "launch-failed";
|
|
75
|
+
reason: string;
|
|
76
|
+
} | {
|
|
77
|
+
kind: "incomplete";
|
|
78
|
+
reason: string;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The ExecutorAdapter interface (DR-0007, M1-P4 grounding). The ENTIRE
|
|
82
|
+
* contract is: write the launch record tasks/<id>/executor.json at
|
|
83
|
+
* launch, launch the payload in the task worktree, and ensure the
|
|
84
|
+
* turn-end file is written with the payload exit code on completion. All
|
|
85
|
+
* state crosses this boundary through files and exit codes, never
|
|
86
|
+
* through terminal inspection (FM-055: pane scraping is a race farm;
|
|
87
|
+
* FM-060: every toolbelt boundary is a subprocess with an exit code).
|
|
88
|
+
* A multiplexer-window adapter and a cloud-session adapter are further
|
|
89
|
+
* adapters against this same interface, not kernel changes (M4 era).
|
|
90
|
+
*/
|
|
91
|
+
export interface ExecutorAdapter {
|
|
92
|
+
readonly name: string;
|
|
93
|
+
launch(request: ExecutorRequest): LaunchOutcome;
|
|
94
|
+
}
|
|
95
|
+
/** The launch record (JSON per DR-0006, shape per PR-207). */
|
|
96
|
+
export interface ExecutorRecord {
|
|
97
|
+
adapter: string;
|
|
98
|
+
launchedAt: string;
|
|
99
|
+
/**
|
|
100
|
+
* Absolute ISO-8601 instant by which the task must have completed,
|
|
101
|
+
* present only when --deadline was passed. Recorded as an instant
|
|
102
|
+
* rather than as the raw seconds so the M1-P5 watcher compares it
|
|
103
|
+
* against the clock directly, the way it already compares lease
|
|
104
|
+
* expiry, instead of re-deriving it from launchedAt. The plan fixes
|
|
105
|
+
* the field name and its optionality, not its units; this is the
|
|
106
|
+
* choice, recorded rather than assumed.
|
|
107
|
+
*/
|
|
108
|
+
deadline?: string;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The local subprocess adapter, the one M1 ships (and the one the exit
|
|
112
|
+
* test's stub payload uses).
|
|
113
|
+
*
|
|
114
|
+
* The payload runs to COMPLETION before this returns: the kernel never
|
|
115
|
+
* auto-backgrounds anything (plan constraint C-3, FM-054), so there is
|
|
116
|
+
* no daemonize path here to forget to guard.
|
|
117
|
+
*
|
|
118
|
+
* It runs without a shell on purpose. Under a shell a missing payload
|
|
119
|
+
* binary arrives as an ordinary exit code 127, indistinguishable from a
|
|
120
|
+
* payload that ran and failed, and spawn's rollback rule turns on
|
|
121
|
+
* exactly that distinction.
|
|
122
|
+
*/
|
|
123
|
+
export declare const subprocessAdapter: ExecutorAdapter;
|
|
124
|
+
/**
|
|
125
|
+
* Liveness-guard seam (plan step 4). The guard itself is M1-P5 work; in
|
|
126
|
+
* this phase it is a documented no-op, so that P5 adds a body here
|
|
127
|
+
* rather than a new call site in the middle of the spawn sequence.
|
|
128
|
+
*/
|
|
129
|
+
export declare function livenessGuard(fleet: Fleet): GuardResult;
|
|
130
|
+
/**
|
|
131
|
+
* Tokenise --exec. M1 supports a program plus plain arguments separated
|
|
132
|
+
* by whitespace; there is no shell and no quoting. Anything richer is an
|
|
133
|
+
* adapter's business (M4 era), not a string this kernel re-parses.
|
|
134
|
+
*/
|
|
135
|
+
export declare function parseExecCommand(command: string): string[];
|
|
136
|
+
export interface SpawnOptions {
|
|
137
|
+
taskId: string;
|
|
138
|
+
project: string;
|
|
139
|
+
briefFile: string;
|
|
140
|
+
shape: TaskShape;
|
|
141
|
+
exec: string;
|
|
142
|
+
deadlineSeconds: number | undefined;
|
|
143
|
+
/** Passed straight through to pool create (EXT-F-03); see spawnTask. */
|
|
144
|
+
offline: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* DECLARED ESCAPE HATCH from the credential scrub (M2-P8 criterion 1).
|
|
147
|
+
* When true, both children inherit the parent environment unchanged,
|
|
148
|
+
* including any pull-request-capable credential the parent holds. This
|
|
149
|
+
* exists for the orchestrator's own spawns, never for an implementer
|
|
150
|
+
* payload; default is false and the scrub is on.
|
|
151
|
+
*/
|
|
152
|
+
allowPrCredentials?: boolean;
|
|
153
|
+
adapter?: ExecutorAdapter;
|
|
154
|
+
}
|
|
155
|
+
export interface SpawnSuccess {
|
|
156
|
+
meta: TaskMeta;
|
|
157
|
+
exitCode: number;
|
|
158
|
+
}
|
|
159
|
+
export type SpawnResult = {
|
|
160
|
+
ok: true;
|
|
161
|
+
value: SpawnSuccess;
|
|
162
|
+
} | {
|
|
163
|
+
ok: false;
|
|
164
|
+
reason: string;
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* One spawn. See the module docs for the rollback contract.
|
|
168
|
+
*
|
|
169
|
+
* --offline is passed straight through to pool create and is the only
|
|
170
|
+
* way a fleet can reach an offline base. meta.baseOffline is then COPIED
|
|
171
|
+
* from the pool record the create returned, never recomputed from this
|
|
172
|
+
* flag: pool create attempts the fetch even under --offline and records
|
|
173
|
+
* offline false when it succeeds, so a spawn that trusted its own flag
|
|
174
|
+
* would mark a perfectly fetched base as offline (PR-212, and the
|
|
175
|
+
* provenance inversion measured as V-2 in M1-P3).
|
|
176
|
+
*/
|
|
177
|
+
export declare function spawnTask(fleet: Fleet, options: SpawnOptions): Promise<SpawnResult>;
|
|
@@ -0,0 +1,332 @@
|
|
|
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 { assembleBrief } from "./brief.js";
|
|
5
|
+
import { buildChildEnv, scrubRoot } from "./exec/env.js";
|
|
6
|
+
import { writeTurnEndHook } from "./hooks.js";
|
|
7
|
+
import { poolCreate, poolDestroy, worktreePath } from "./pool.js";
|
|
8
|
+
import { checkHoldership, executorRecordPath, metaPath, runStep, taskDir, taskDirExists, taskDirOccupied, writeTaskMeta, } from "./task.js";
|
|
9
|
+
/**
|
|
10
|
+
* A payload terminated by a signal has no exit code. The shell's
|
|
11
|
+
* convention (128 + signal number) is used so the turn-end record always
|
|
12
|
+
* carries a number. M1 never drives this path; it exists so that the
|
|
13
|
+
* type is honest rather than filled with a guess.
|
|
14
|
+
*/
|
|
15
|
+
function payloadExitCode(status, signal) {
|
|
16
|
+
if (status !== null) {
|
|
17
|
+
return status;
|
|
18
|
+
}
|
|
19
|
+
const signals = constants.signals;
|
|
20
|
+
const number = signal === null ? undefined : signals[signal];
|
|
21
|
+
return 128 + (number ?? 0);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The local subprocess adapter, the one M1 ships (and the one the exit
|
|
25
|
+
* test's stub payload uses).
|
|
26
|
+
*
|
|
27
|
+
* The payload runs to COMPLETION before this returns: the kernel never
|
|
28
|
+
* auto-backgrounds anything (plan constraint C-3, FM-054), so there is
|
|
29
|
+
* no daemonize path here to forget to guard.
|
|
30
|
+
*
|
|
31
|
+
* It runs without a shell on purpose. Under a shell a missing payload
|
|
32
|
+
* binary arrives as an ordinary exit code 127, indistinguishable from a
|
|
33
|
+
* payload that ran and failed, and spawn's rollback rule turns on
|
|
34
|
+
* exactly that distinction.
|
|
35
|
+
*/
|
|
36
|
+
export const subprocessAdapter = {
|
|
37
|
+
name: "subprocess",
|
|
38
|
+
launch(request) {
|
|
39
|
+
const launchedAt = new Date();
|
|
40
|
+
const record = {
|
|
41
|
+
adapter: "subprocess",
|
|
42
|
+
launchedAt: launchedAt.toISOString(),
|
|
43
|
+
};
|
|
44
|
+
if (request.deadlineSeconds !== undefined) {
|
|
45
|
+
record.deadline = new Date(launchedAt.getTime() + request.deadlineSeconds * 1000).toISOString();
|
|
46
|
+
}
|
|
47
|
+
// The record write happens BEFORE the payload, so a failure here is
|
|
48
|
+
// provably a launch failure and is safe to roll back. Everything
|
|
49
|
+
// after the payload starts is reported as incomplete instead, which
|
|
50
|
+
// never rolls anything back (F-2's fix must not become V-1's defect).
|
|
51
|
+
const written = runStep(`writing the launch record ${request.recordPath}`, () => {
|
|
52
|
+
writeFileSync(request.recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
53
|
+
});
|
|
54
|
+
if (!written.ok) {
|
|
55
|
+
return { kind: "launch-failed", reason: written.reason };
|
|
56
|
+
}
|
|
57
|
+
const [program, ...args] = request.command;
|
|
58
|
+
if (program === undefined) {
|
|
59
|
+
return { kind: "launch-failed", reason: "empty --exec command" };
|
|
60
|
+
}
|
|
61
|
+
const result = spawnSync(program, args, {
|
|
62
|
+
cwd: request.worktree,
|
|
63
|
+
stdio: "inherit",
|
|
64
|
+
// The scrubbed environment (M2-P8). Spread rather than `env:
|
|
65
|
+
// request.env` so an undefined request.env means "no env option at
|
|
66
|
+
// all", which is Node's documented full-inheritance form.
|
|
67
|
+
...(request.env === undefined ? {} : { env: request.env }),
|
|
68
|
+
});
|
|
69
|
+
if (result.error !== undefined) {
|
|
70
|
+
return {
|
|
71
|
+
kind: "launch-failed",
|
|
72
|
+
reason: `cannot launch ${program}: ${String(result.error)}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const exitCode = payloadExitCode(result.status, result.signal);
|
|
76
|
+
// The payload has run. Every failure below, raised or returned, is
|
|
77
|
+
// reported as incomplete: the worktree may hold real work now, so
|
|
78
|
+
// nothing here may lead to a rollback.
|
|
79
|
+
const hooked = runStep(`invoking the turn-end hook ${request.hookPath}`, () => spawnSync(process.execPath, [request.hookPath, String(exitCode)], {
|
|
80
|
+
stdio: "inherit",
|
|
81
|
+
// The hook child gets the SAME scrubbed environment as the
|
|
82
|
+
// payload (M2R-004 edit 4): a second launch nobody scrubbed is
|
|
83
|
+
// exactly the leak the finding names. The generated hook script
|
|
84
|
+
// itself reads no environment at all (src/hooks.ts), so the
|
|
85
|
+
// scrub cannot break it.
|
|
86
|
+
...(request.env === undefined ? {} : { env: request.env }),
|
|
87
|
+
}));
|
|
88
|
+
if (!hooked.ok) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "incomplete",
|
|
91
|
+
reason: `the payload exited ${String(exitCode)} but the turn-end record could not ` +
|
|
92
|
+
`be written (${hooked.reason}); the worktree and the task directory are ` +
|
|
93
|
+
`left in place`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const hook = hooked.value;
|
|
97
|
+
if (hook.error !== undefined || hook.status !== 0) {
|
|
98
|
+
const detail = hook.error === undefined ? `exit ${String(hook.status)}` : String(hook.error);
|
|
99
|
+
return {
|
|
100
|
+
kind: "incomplete",
|
|
101
|
+
reason: `the payload exited ${String(exitCode)} but the turn-end hook ` +
|
|
102
|
+
`${request.hookPath} failed (${detail}); the worktree and the task ` +
|
|
103
|
+
`directory are left in place`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { kind: "completed", exitCode };
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Liveness-guard seam (plan step 4). The guard itself is M1-P5 work; in
|
|
111
|
+
* this phase it is a documented no-op, so that P5 adds a body here
|
|
112
|
+
* rather than a new call site in the middle of the spawn sequence.
|
|
113
|
+
*/
|
|
114
|
+
export function livenessGuard(fleet) {
|
|
115
|
+
void fleet;
|
|
116
|
+
return { ok: true };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Tokenise --exec. M1 supports a program plus plain arguments separated
|
|
120
|
+
* by whitespace; there is no shell and no quoting. Anything richer is an
|
|
121
|
+
* adapter's business (M4 era), not a string this kernel re-parses.
|
|
122
|
+
*/
|
|
123
|
+
export function parseExecCommand(command) {
|
|
124
|
+
return command.split(/\s+/u).filter((token) => token !== "");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* One spawn. See the module docs for the rollback contract.
|
|
128
|
+
*
|
|
129
|
+
* --offline is passed straight through to pool create and is the only
|
|
130
|
+
* way a fleet can reach an offline base. meta.baseOffline is then COPIED
|
|
131
|
+
* from the pool record the create returned, never recomputed from this
|
|
132
|
+
* flag: pool create attempts the fetch even under --offline and records
|
|
133
|
+
* offline false when it succeeds, so a spawn that trusted its own flag
|
|
134
|
+
* would mark a perfectly fetched base as offline (PR-212, and the
|
|
135
|
+
* provenance inversion measured as V-2 in M1-P3).
|
|
136
|
+
*/
|
|
137
|
+
export async function spawnTask(fleet, options) {
|
|
138
|
+
const { taskId } = options;
|
|
139
|
+
const liveness = livenessGuard(fleet);
|
|
140
|
+
if (!liveness.ok) {
|
|
141
|
+
return { ok: false, reason: liveness.reason };
|
|
142
|
+
}
|
|
143
|
+
const holdership = checkHoldership(fleet);
|
|
144
|
+
if (!holdership.ok) {
|
|
145
|
+
return { ok: false, reason: holdership.reason };
|
|
146
|
+
}
|
|
147
|
+
const command = parseExecCommand(options.exec);
|
|
148
|
+
if (command.length === 0) {
|
|
149
|
+
return { ok: false, reason: "--exec is empty" };
|
|
150
|
+
}
|
|
151
|
+
// CR-301, checked before pool create so the refusal creates nothing and
|
|
152
|
+
// destroys nothing. tasks/<id>/ survives every teardown by design, so a
|
|
153
|
+
// reused id would otherwise overwrite a closed task's records, hand the
|
|
154
|
+
// rollback files it did not create, and leave the previous
|
|
155
|
+
// incarnation's turn-end readable beside a meta that says open.
|
|
156
|
+
if (taskDirOccupied(fleet, taskId)) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: `task directory ${taskDir(fleet, taskId)} already holds records for task ` +
|
|
160
|
+
`id ${taskId}; a task id is spawned once, so choose a fresh id or move ` +
|
|
161
|
+
`that directory aside before re-using this one`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const created = await poolCreate(fleet, {
|
|
165
|
+
taskId,
|
|
166
|
+
project: options.project,
|
|
167
|
+
offline: options.offline,
|
|
168
|
+
});
|
|
169
|
+
if (!created.ok) {
|
|
170
|
+
return { ok: false, reason: created.reason };
|
|
171
|
+
}
|
|
172
|
+
const poolRecord = created.value;
|
|
173
|
+
const worktree = worktreePath(fleet, taskId);
|
|
174
|
+
// Everything below is rollback-scoped: this list holds exactly what
|
|
175
|
+
// THIS invocation created, in creation order.
|
|
176
|
+
const createdFiles = [];
|
|
177
|
+
const dir = taskDir(fleet, taskId);
|
|
178
|
+
const createdTaskDir = !taskDirExists(fleet, taskId);
|
|
179
|
+
const rollback = async (reason) => {
|
|
180
|
+
for (const file of [...createdFiles].reverse()) {
|
|
181
|
+
try {
|
|
182
|
+
unlinkSync(file);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Never written, or already gone.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// The scrub root (harness-owned redirect targets, M2-P8) is created
|
|
189
|
+
// by THIS invocation strictly before the launch, and this rollback
|
|
190
|
+
// only ever runs before the payload has started, so removing it
|
|
191
|
+
// recursively removes only what this invocation staged. It sits
|
|
192
|
+
// inside the task directory, never inside the worktree.
|
|
193
|
+
try {
|
|
194
|
+
rmSync(scrubRoot(dir), { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Never created, or already gone.
|
|
198
|
+
}
|
|
199
|
+
if (createdTaskDir) {
|
|
200
|
+
try {
|
|
201
|
+
rmdirSync(dir);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Left in place when it is not empty: whatever is in it was not
|
|
205
|
+
// created by this invocation, and this rollback does not own it.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const destroyed = await poolDestroy(fleet, {
|
|
209
|
+
taskId,
|
|
210
|
+
discard: false,
|
|
211
|
+
deleteBranchForce: false,
|
|
212
|
+
});
|
|
213
|
+
if (!destroyed.ok) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
reason: `${reason}; rollback of the worktree did not complete: ${destroyed.reason}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { ok: false, reason };
|
|
220
|
+
};
|
|
221
|
+
// From here to the launch, EVERY step goes through runStep: a raised
|
|
222
|
+
// fs error is folded into the same ok/reason shape a returned failure
|
|
223
|
+
// uses, so one handler covers both and the rollback cannot be walked
|
|
224
|
+
// past (F-2).
|
|
225
|
+
if (createdTaskDir) {
|
|
226
|
+
const made = runStep(`creating the task directory ${dir}`, () => {
|
|
227
|
+
mkdirSync(dir, { recursive: true });
|
|
228
|
+
});
|
|
229
|
+
if (!made.ok) {
|
|
230
|
+
return rollback(made.reason);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const brief = runStep(`assembling the brief for task ${taskId}`, () => assembleBrief(fleet, taskId, options.briefFile));
|
|
234
|
+
if (!brief.ok) {
|
|
235
|
+
return rollback(brief.reason);
|
|
236
|
+
}
|
|
237
|
+
if (!brief.value.ok) {
|
|
238
|
+
return rollback(brief.value.reason);
|
|
239
|
+
}
|
|
240
|
+
createdFiles.push(brief.value.value);
|
|
241
|
+
const meta = {
|
|
242
|
+
id: taskId,
|
|
243
|
+
project: poolRecord.project,
|
|
244
|
+
shape: options.shape,
|
|
245
|
+
branch: poolRecord.branchName,
|
|
246
|
+
worktree,
|
|
247
|
+
baseSha: poolRecord.baseSha,
|
|
248
|
+
baseOffline: poolRecord.offline,
|
|
249
|
+
status: "open",
|
|
250
|
+
createdAt: new Date().toISOString(),
|
|
251
|
+
};
|
|
252
|
+
const wroteMeta = runStep(`writing ${metaPath(fleet, taskId)}`, () => {
|
|
253
|
+
writeTaskMeta(fleet, meta);
|
|
254
|
+
});
|
|
255
|
+
if (!wroteMeta.ok) {
|
|
256
|
+
return rollback(wroteMeta.reason);
|
|
257
|
+
}
|
|
258
|
+
createdFiles.push(metaPath(fleet, taskId));
|
|
259
|
+
const hook = runStep(`writing the turn-end hook for task ${taskId}`, () => writeTurnEndHook(fleet, taskId));
|
|
260
|
+
if (!hook.ok) {
|
|
261
|
+
return rollback(hook.reason);
|
|
262
|
+
}
|
|
263
|
+
const hookPath = hook.value;
|
|
264
|
+
createdFiles.push(hookPath);
|
|
265
|
+
const recordPath = executorRecordPath(fleet, taskId);
|
|
266
|
+
createdFiles.push(recordPath);
|
|
267
|
+
// The child environment (M2-P8): built from the allowlist with the
|
|
268
|
+
// credential-store pointers redirected into this task's directory,
|
|
269
|
+
// unless the caller passed the declared escape hatch. Built BEFORE the
|
|
270
|
+
// launch so a staging failure is a rollback, never a half-scrubbed
|
|
271
|
+
// child.
|
|
272
|
+
let childEnv;
|
|
273
|
+
if (options.allowPrCredentials !== true) {
|
|
274
|
+
const built = runStep(`constructing the scrubbed child environment for task ${taskId}`, () => buildChildEnv({ parentEnv: process.env, scrubDir: scrubRoot(dir) }));
|
|
275
|
+
if (!built.ok) {
|
|
276
|
+
return rollback(built.reason);
|
|
277
|
+
}
|
|
278
|
+
if (!built.value.ok) {
|
|
279
|
+
return rollback(built.value.reason);
|
|
280
|
+
}
|
|
281
|
+
childEnv = built.value.env;
|
|
282
|
+
}
|
|
283
|
+
const adapter = options.adapter ?? subprocessAdapter;
|
|
284
|
+
const launched = runStep(`launching the payload through the ${adapter.name} adapter`, () => adapter.launch({
|
|
285
|
+
taskId,
|
|
286
|
+
worktree,
|
|
287
|
+
command,
|
|
288
|
+
hookPath,
|
|
289
|
+
recordPath,
|
|
290
|
+
deadlineSeconds: options.deadlineSeconds,
|
|
291
|
+
env: childEnv,
|
|
292
|
+
}));
|
|
293
|
+
if (!launched.ok) {
|
|
294
|
+
// An adapter that THREW rather than returning an outcome cannot tell
|
|
295
|
+
// us whether the payload started, and this rollback destroys a
|
|
296
|
+
// worktree. Refusing to guess is the whole lesson of V-1: the state
|
|
297
|
+
// is left in place and enumerated instead.
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
reason: `${launched.reason}; the ${adapter.name} adapter did not report whether the ` +
|
|
301
|
+
`payload started, so nothing was rolled back: the worktree ${worktree}, its ` +
|
|
302
|
+
`task directory and the pool record are left in place for inspection; when ` +
|
|
303
|
+
`you have inspected them, close the task with "tiphys teardown --task ${taskId}"`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const outcome = launched.value;
|
|
307
|
+
if (outcome.kind === "launch-failed") {
|
|
308
|
+
return rollback(`executor launch failed: ${outcome.reason}`);
|
|
309
|
+
}
|
|
310
|
+
if (outcome.kind === "incomplete") {
|
|
311
|
+
// The payload ran, so nothing is rolled back, and the reason says so.
|
|
312
|
+
// The scrub root is deliberately LEFT in place here: the hook child
|
|
313
|
+
// failed, and whatever the children left under the redirected paths
|
|
314
|
+
// is part of the state an operator inspects.
|
|
315
|
+
return { ok: false, reason: outcome.reason };
|
|
316
|
+
}
|
|
317
|
+
// The scrub root is ephemeral. Both children have exited (the launch is
|
|
318
|
+
// synchronous, C-3), so the harness-owned redirect targets have no
|
|
319
|
+
// further reader; removing them returns the task directory to its
|
|
320
|
+
// documented records-only shape. This removal touches ONLY the scrub
|
|
321
|
+
// root, never the worktree, so it cannot be a V-1-shaped loss.
|
|
322
|
+
if (childEnv !== undefined) {
|
|
323
|
+
try {
|
|
324
|
+
rmSync(scrubRoot(dir), { recursive: true, force: true });
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// A leftover empty scrub directory is benign; failing a completed
|
|
328
|
+
// spawn over its cleanup would not be.
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { ok: true, value: { meta, exitCode: outcome.exitCode } };
|
|
332
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STATUS LINE (kernel plan M3, M3-P1 steps 5 and 6; R-084).
|
|
3
|
+
*
|
|
4
|
+
* The orchestrator narrates to the owner at MILESTONES ONLY: a phase merged,
|
|
5
|
+
* an incident, a decision needed. Routine noise gets a one-line ack and
|
|
6
|
+
* nothing else. That sparseness is expressed STRUCTURALLY in
|
|
7
|
+
* `schemas/status-line.schema.json` rather than as an instruction: the state
|
|
8
|
+
* vocabulary has exactly five members and none of them means "still working",
|
|
9
|
+
* so a heartbeat is not a thing this contract can say.
|
|
10
|
+
*
|
|
11
|
+
* CONSTRAINT C-1, NAMED BECAUSE IT DECIDES THE SHAPE OF THIS MODULE.
|
|
12
|
+
* "Never read current state from the tail of an append-only log." The stream
|
|
13
|
+
* at `state/status/stream.jsonl` is the HISTORY and is append-only. The
|
|
14
|
+
* current state is `state/status/current.json`, a whole document rewritten
|
|
15
|
+
* atomically on every emit. `readCurrent` opens `current.json` and nothing
|
|
16
|
+
* else; it does not know the stream's path. A truncated, corrupted or
|
|
17
|
+
* half-written stream therefore cannot change what `tiphys status show`
|
|
18
|
+
* reports, and that is the property criterion 7 witnesses in both directions.
|
|
19
|
+
*
|
|
20
|
+
* ATOMIC REWRITE means write a temp file beside the target and rename. A
|
|
21
|
+
* rename within one directory is atomic on POSIX, so a reader either sees the
|
|
22
|
+
* whole previous document or the whole new one and never a partial write.
|
|
23
|
+
*/
|
|
24
|
+
/** Where the status files live inside a fleet home. */
|
|
25
|
+
export declare const STATUS_DIR: string;
|
|
26
|
+
export declare const STREAM_FILE: string;
|
|
27
|
+
export declare const CURRENT_FILE: string;
|
|
28
|
+
/**
|
|
29
|
+
* The five supervisor-actionable states, blueprint section 5. Duplicated
|
|
30
|
+
* from `schemas/status-line.schema.json` so the CLI can reject a bad state
|
|
31
|
+
* before it composes a record; `test/status.test.ts` asserts the two agree,
|
|
32
|
+
* because a duplicated vocabulary that nothing compares is a vocabulary that
|
|
33
|
+
* drifts.
|
|
34
|
+
*/
|
|
35
|
+
export declare const STATUS_STATES: readonly string[];
|
|
36
|
+
export interface StatusRecord {
|
|
37
|
+
kind: "status-line";
|
|
38
|
+
at: string;
|
|
39
|
+
run: string;
|
|
40
|
+
project: string;
|
|
41
|
+
state: string;
|
|
42
|
+
detail: string;
|
|
43
|
+
refs: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface EmitInput {
|
|
46
|
+
run: string;
|
|
47
|
+
project: string;
|
|
48
|
+
state: string;
|
|
49
|
+
detail?: string;
|
|
50
|
+
refs?: string[];
|
|
51
|
+
/** Injected so a test can assert an exact record rather than a shape. */
|
|
52
|
+
at?: string;
|
|
53
|
+
}
|
|
54
|
+
/** RFC 3339 UTC to whole seconds, the form the schema's pattern accepts. */
|
|
55
|
+
export declare function nowIso(): string;
|
|
56
|
+
export declare function makeStatusRecord(input: EmitInput): StatusRecord;
|
|
57
|
+
export type EmitOutcome = {
|
|
58
|
+
ok: true;
|
|
59
|
+
record: StatusRecord;
|
|
60
|
+
} | {
|
|
61
|
+
ok: false;
|
|
62
|
+
reason: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Append ONE line to the stream, then rewrite `current.json` atomically.
|
|
66
|
+
*
|
|
67
|
+
* The order is deliberate and is the same ordering rule M2's gate runner
|
|
68
|
+
* learned: the durable history is written first, so a crash between the two
|
|
69
|
+
* leaves a history that is ahead of the pointer rather than a pointer that
|
|
70
|
+
* names an event no record supports.
|
|
71
|
+
*/
|
|
72
|
+
export declare function emitStatus(fleetRoot: string, record: StatusRecord): EmitOutcome;
|
|
73
|
+
export type CurrentRead = {
|
|
74
|
+
ok: true;
|
|
75
|
+
record: StatusRecord;
|
|
76
|
+
} | {
|
|
77
|
+
ok: false;
|
|
78
|
+
reason: string;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Read the current status. C-1: this function knows only `current.json`.
|
|
82
|
+
*
|
|
83
|
+
* It does not accept a stream path, does not fall back to the stream, and
|
|
84
|
+
* has no code path that opens it. That is what makes criterion 7 a
|
|
85
|
+
* demonstrable property rather than a convention: an implementation that
|
|
86
|
+
* reads the stream is a DIFFERENT function, and the criterion requires it to
|
|
87
|
+
* be written, shown red against corrupt stream bytes, and reverted.
|
|
88
|
+
*/
|
|
89
|
+
export declare function readCurrent(fleetRoot: string): CurrentRead;
|
|
90
|
+
/** One line, the form `tiphys status show` prints. */
|
|
91
|
+
export declare function renderStatus(record: StatusRecord): string;
|