@phyzess/pappra 0.0.0-stage → 0.1.0-beta.1
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/LICENSE +21 -0
- package/README.md +80 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/integrations/skills/pappra/SKILL.md +68 -0
- package/assets/integrations/skills/pappra/references/hosts.md +30 -0
- package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
- package/assets/integrations/skills/pappra/references/operations.md +14 -0
- package/assets/manifest.json +4 -0
- package/dist/app/collaboration.d.ts +170 -0
- package/dist/app/collaboration.js +1650 -0
- package/dist/app/doctor.d.ts +9 -0
- package/dist/app/doctor.js +1030 -0
- package/dist/app/init.d.ts +143 -0
- package/dist/app/init.js +1111 -0
- package/dist/cli/commands/code.d.ts +16 -0
- package/dist/cli/commands/code.js +127 -0
- package/dist/cli/commands/collaboration.d.ts +13 -0
- package/dist/cli/commands/collaboration.js +186 -0
- package/dist/cli/commands/continuity.d.ts +29 -0
- package/dist/cli/commands/continuity.js +133 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +133 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +57 -0
- package/dist/cli/commands/host.d.ts +13 -0
- package/dist/cli/commands/host.js +125 -0
- package/dist/cli/commands/init.d.ts +51 -0
- package/dist/cli/commands/init.js +668 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +10 -0
- package/dist/cli/output.d.ts +4 -0
- package/dist/cli/output.js +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +597 -0
- package/dist/code/budget.d.ts +14 -0
- package/dist/code/budget.js +39 -0
- package/dist/code/exec.d.ts +23 -0
- package/dist/code/exec.js +133 -0
- package/dist/code/identity.d.ts +13 -0
- package/dist/code/identity.js +214 -0
- package/dist/code/index.d.ts +6 -0
- package/dist/code/index.js +3 -0
- package/dist/code/providers/codegraph.d.ts +59 -0
- package/dist/code/providers/codegraph.js +567 -0
- package/dist/code/registration.d.ts +9 -0
- package/dist/code/registration.js +293 -0
- package/dist/code/service.d.ts +53 -0
- package/dist/code/service.js +633 -0
- package/dist/code/text.d.ts +3 -0
- package/dist/code/text.js +202 -0
- package/dist/collab/admission.d.ts +450 -0
- package/dist/collab/admission.js +797 -0
- package/dist/collab/canonical.d.ts +26 -0
- package/dist/collab/canonical.js +136 -0
- package/dist/collab/contracts.d.ts +73 -0
- package/dist/collab/contracts.js +76 -0
- package/dist/collab/envelope.d.ts +52 -0
- package/dist/collab/envelope.js +241 -0
- package/dist/collab/fingerprint.d.ts +7 -0
- package/dist/collab/fingerprint.js +58 -0
- package/dist/collab/handoff.d.ts +350 -0
- package/dist/collab/handoff.js +513 -0
- package/dist/collab/index.d.ts +9 -0
- package/dist/collab/index.js +8 -0
- package/dist/collab/lifecycle.d.ts +449 -0
- package/dist/collab/lifecycle.js +534 -0
- package/dist/collab/messages.d.ts +148 -0
- package/dist/collab/messages.js +583 -0
- package/dist/collab/notify.d.ts +47 -0
- package/dist/collab/notify.js +140 -0
- package/dist/collab/preflight.d.ts +31 -0
- package/dist/collab/preflight.js +271 -0
- package/dist/collab/resources.d.ts +119 -0
- package/dist/collab/resources.js +451 -0
- package/dist/collab/store.d.ts +143 -0
- package/dist/collab/store.js +606 -0
- package/dist/collab/types.d.ts +354 -0
- package/dist/collab/types.js +1 -0
- package/dist/collab/validation.d.ts +10 -0
- package/dist/collab/validation.js +303 -0
- package/dist/continuity/context.d.ts +24 -0
- package/dist/continuity/context.js +179 -0
- package/dist/continuity/index.d.ts +3 -0
- package/dist/continuity/index.js +1 -0
- package/dist/continuity/paths.d.ts +37 -0
- package/dist/continuity/paths.js +258 -0
- package/dist/continuity/service.d.ts +59 -0
- package/dist/continuity/service.js +1258 -0
- package/dist/hosts/claude/native.d.ts +79 -0
- package/dist/hosts/claude/native.js +853 -0
- package/dist/hosts/codex/native.d.ts +345 -0
- package/dist/hosts/codex/native.js +1528 -0
- package/dist/hosts/codex/protocol.d.ts +51 -0
- package/dist/hosts/codex/protocol.js +42 -0
- package/dist/hosts/detect.d.ts +28 -0
- package/dist/hosts/detect.js +73 -0
- package/dist/hosts/index.d.ts +10 -0
- package/dist/hosts/index.js +10 -0
- package/dist/hosts/integration/bundle.d.ts +16 -0
- package/dist/hosts/integration/bundle.js +81 -0
- package/dist/hosts/integration/hook-entry.d.ts +1 -0
- package/dist/hosts/integration/hook-entry.js +68 -0
- package/dist/hosts/integration/hook.d.ts +32 -0
- package/dist/hosts/integration/hook.js +781 -0
- package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
- package/dist/hosts/integration/pi-extension-runtime.js +89 -0
- package/dist/hosts/integration/pi-template.d.ts +3 -0
- package/dist/hosts/integration/pi-template.js +5 -0
- package/dist/hosts/integration/service.d.ts +41 -0
- package/dist/hosts/integration/service.js +1381 -0
- package/dist/hosts/integration/support.d.ts +36 -0
- package/dist/hosts/integration/support.js +41 -0
- package/dist/hosts/integration/templates.d.ts +22 -0
- package/dist/hosts/integration/templates.js +70 -0
- package/dist/hosts/pi/protocol.d.ts +22 -0
- package/dist/hosts/pi/protocol.js +28 -0
- package/dist/hosts/pi/query-result.d.ts +22 -0
- package/dist/hosts/pi/query-result.js +710 -0
- package/dist/hosts/pi/rpc.d.ts +937 -0
- package/dist/hosts/pi/rpc.js +1621 -0
- package/dist/hosts/pilot.d.ts +485 -0
- package/dist/hosts/pilot.js +593 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/infra/fs.d.ts +27 -0
- package/dist/infra/fs.js +130 -0
- package/dist/infra/git.d.ts +60 -0
- package/dist/infra/git.js +358 -0
- package/dist/infra/owned-process-group.d.ts +173 -0
- package/dist/infra/owned-process-group.js +571 -0
- package/dist/install/apply.d.ts +65 -0
- package/dist/install/apply.js +663 -0
- package/dist/install/index.d.ts +5 -0
- package/dist/install/index.js +5 -0
- package/dist/install/ownership.d.ts +85 -0
- package/dist/install/ownership.js +430 -0
- package/dist/install/plan.d.ts +39 -0
- package/dist/install/plan.js +97 -0
- package/dist/install/rollback.d.ts +18 -0
- package/dist/install/rollback.js +544 -0
- package/dist/install/runtime.d.ts +76 -0
- package/dist/install/runtime.js +1145 -0
- package/dist/knowledge/catalog.d.ts +47 -0
- package/dist/knowledge/catalog.js +308 -0
- package/dist/knowledge/context.d.ts +28 -0
- package/dist/knowledge/context.js +61 -0
- package/dist/knowledge/index.d.ts +3 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/journal.d.ts +16 -0
- package/dist/knowledge/journal.js +109 -0
- package/dist/knowledge/lifecycle.d.ts +8 -0
- package/dist/knowledge/lifecycle.js +1001 -0
- package/dist/knowledge/paths.d.ts +37 -0
- package/dist/knowledge/paths.js +236 -0
- package/dist/knowledge/refs.d.ts +36 -0
- package/dist/knowledge/refs.js +267 -0
- package/dist/knowledge/service.d.ts +21 -0
- package/dist/knowledge/service.js +393 -0
- package/dist/knowledge/types.d.ts +171 -0
- package/dist/knowledge/types.js +1 -0
- package/dist/knowledge/write.d.ts +8 -0
- package/dist/knowledge/write.js +1223 -0
- package/dist/package-info.d.ts +6 -0
- package/dist/package-info.js +19 -0
- package/dist/schemas/code.d.ts +377 -0
- package/dist/schemas/code.js +145 -0
- package/dist/schemas/collaboration.d.ts +115 -0
- package/dist/schemas/collaboration.js +106 -0
- package/dist/schemas/continuity.d.ts +315 -0
- package/dist/schemas/continuity.js +199 -0
- package/dist/schemas/diagnostics.d.ts +28 -0
- package/dist/schemas/diagnostics.js +27 -0
- package/dist/schemas/doctor.d.ts +157 -0
- package/dist/schemas/doctor.js +75 -0
- package/dist/schemas/document.d.ts +644 -0
- package/dist/schemas/document.js +296 -0
- package/dist/schemas/errors.d.ts +177 -0
- package/dist/schemas/errors.js +176 -0
- package/dist/schemas/evidence.d.ts +24 -0
- package/dist/schemas/evidence.js +16 -0
- package/dist/schemas/host.d.ts +436 -0
- package/dist/schemas/host.js +201 -0
- package/dist/schemas/index.d.ts +15 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/init.d.ts +121 -0
- package/dist/schemas/init.js +94 -0
- package/dist/schemas/install.d.ts +213 -0
- package/dist/schemas/install.js +139 -0
- package/dist/schemas/machine.d.ts +43 -0
- package/dist/schemas/machine.js +96 -0
- package/dist/schemas/outcome.d.ts +23 -0
- package/dist/schemas/outcome.js +17 -0
- package/dist/schemas/paths.d.ts +19 -0
- package/dist/schemas/paths.js +63 -0
- package/dist/schemas/workspace.d.ts +120 -0
- package/dist/schemas/workspace.js +201 -0
- package/dist/workspace/config-file.d.ts +36 -0
- package/dist/workspace/config-file.js +237 -0
- package/dist/workspace/config.d.ts +28 -0
- package/dist/workspace/config.js +79 -0
- package/dist/workspace/discover.d.ts +34 -0
- package/dist/workspace/discover.js +195 -0
- package/dist/workspace/documents.d.ts +21 -0
- package/dist/workspace/documents.js +395 -0
- package/dist/workspace/ignore.d.ts +10 -0
- package/dist/workspace/ignore.js +48 -0
- package/dist/workspace/index.d.ts +7 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/repositories.d.ts +24 -0
- package/dist/workspace/repositories.js +411 -0
- package/dist/workspace/resolve.d.ts +24 -0
- package/dist/workspace/resolve.js +268 -0
- package/dist/workspace/types.d.ts +71 -0
- package/dist/workspace/types.js +1 -0
- package/dist/workspace/validation.d.ts +18 -0
- package/dist/workspace/validation.js +197 -0
- package/package.json +105 -4
- package/skills/pappra/SKILL.md +68 -0
- package/skills/pappra/references/hosts.md +30 -0
- package/skills/pappra/references/lifecycle.md +37 -0
- package/skills/pappra/references/operations.md +14 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { isAssignment, isCard } from './contracts.js';
|
|
2
|
+
import { parseJson } from './validation.js';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { nowIso, readRecord, transact } from './store.js';
|
|
7
|
+
import { preflight } from './preflight.js';
|
|
8
|
+
import { findAttempt, openUncertainty } from './lifecycle.js';
|
|
9
|
+
import { claimsOverlap, describeResource, pathRelation } from './canonical.js';
|
|
10
|
+
// Explicit launcher for one native pilot execution.
|
|
11
|
+
//
|
|
12
|
+
// It exists so the receiving harness's argv, cwd, tool allowance, timeout and identity
|
|
13
|
+
// are recorded next to the native events, and so no harness is invoked by accident: the
|
|
14
|
+
// command refuses to run without `--confirm-run true`.
|
|
15
|
+
//
|
|
16
|
+
// Version-pinned profiles (observed 2026-10-15):
|
|
17
|
+
// pi 1.2.0 : `--print --session-id <uuid> --tools <allowlist> --no-mcp`
|
|
18
|
+
// codex 0.162.1 : `exec --json -C <cwd> -s <sandbox>`; resume via `exec resume <thread>`
|
|
19
|
+
// claude 2.1.287 : `--print --output-format stream-json --setting-sources user
|
|
20
|
+
// --tools <allowlist> --strict-mcp-config --mcp-config {"mcpServers":{}}
|
|
21
|
+
// --settings {"disableAllHooks":true} --max-budget-usd <n>`
|
|
22
|
+
//
|
|
23
|
+
// No model or reasoning-effort override is supplied for any harness; the only limits are
|
|
24
|
+
// the per-run timeout, the tool allowlist and (for Claude) the requested API budget cap.
|
|
25
|
+
export const TOOL_PROFILES = {
|
|
26
|
+
pi: {
|
|
27
|
+
writer: 'read,bash,write,replace,replace_match',
|
|
28
|
+
// The Pi reviewer profile has no write/edit tool, but bash stays available so an
|
|
29
|
+
// independent verifier can run the frozen suite. That is a tool limit, not a sandbox.
|
|
30
|
+
reviewer: 'read,bash,anchor_grep',
|
|
31
|
+
},
|
|
32
|
+
claude: {
|
|
33
|
+
writer: 'Read,Edit,Write,Skill',
|
|
34
|
+
reviewer: 'Read,Skill',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
/** The runner an assignment must name for a given harness; the launcher will not substitute one. */
|
|
38
|
+
const RUNNER_FOR_HARNESS = {
|
|
39
|
+
pi: 'pi-cli',
|
|
40
|
+
codex: 'codex-exec',
|
|
41
|
+
claude: 'claude-cli',
|
|
42
|
+
};
|
|
43
|
+
/** Whether the assignment can name the native session before the process starts. Pi and Claude
|
|
44
|
+
* accept an explicit session id; `codex exec` chooses the thread id itself. */
|
|
45
|
+
const PRE_BOUND_IDENTITY = {
|
|
46
|
+
pi: true,
|
|
47
|
+
codex: false,
|
|
48
|
+
claude: true,
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Pre-dispatch admission. A launcher that started a native process without checking the
|
|
52
|
+
* assignment, the capability snapshot, the collaboration record and the actual resource
|
|
53
|
+
* ownership would be an automatic entry that bypasses the whole contract, so it refuses.
|
|
54
|
+
*
|
|
55
|
+
* Two modes exist:
|
|
56
|
+
* work the default; requires a runtime-ready capability card
|
|
57
|
+
* discovery the restricted path that produces the first runtime observation; it may use a
|
|
58
|
+
* declared card but must require no capability at all, so it cannot claim
|
|
59
|
+
* anything it has not observed
|
|
60
|
+
*/
|
|
61
|
+
export function admitLaunch({ assignmentFile, capabilitiesFile, taskRoot, cwd, sessionId, harness = null, profile = null, launchKind = 'first', discovery = false, resumeSelector = null, now = Date.now(), maxAgeMinutes = 30, }) {
|
|
62
|
+
const read = (file) => {
|
|
63
|
+
const stat = fs.statSync(file);
|
|
64
|
+
if (!stat.isFile() || stat.size > 1024 * 1024)
|
|
65
|
+
throw new Error(`Unsupported JSON file: ${file}`);
|
|
66
|
+
return parseJson(fs.readFileSync(file, 'utf8'));
|
|
67
|
+
};
|
|
68
|
+
const assignment = read(assignmentFile);
|
|
69
|
+
if (!isAssignment(assignment))
|
|
70
|
+
throw new Error('Invalid assignment structure');
|
|
71
|
+
const card = read(capabilitiesFile);
|
|
72
|
+
if (!isCard(card))
|
|
73
|
+
throw new Error('Invalid capability card structure');
|
|
74
|
+
const checks = [];
|
|
75
|
+
const selector = resumeSelector ?? null;
|
|
76
|
+
// 1. the contract must describe the execution that is about to start
|
|
77
|
+
if (harness && assignment.assignee.harness !== harness) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: 'harness_contract_mismatch',
|
|
81
|
+
checks,
|
|
82
|
+
expected: assignment.assignee.harness,
|
|
83
|
+
actual: harness,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const expectedRunner = RUNNER_FOR_HARNESS[harness ?? assignment.assignee.harness];
|
|
87
|
+
if (assignment.runner?.name &&
|
|
88
|
+
expectedRunner &&
|
|
89
|
+
assignment.runner.name !== expectedRunner) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
reason: 'runner_contract_mismatch',
|
|
93
|
+
checks,
|
|
94
|
+
expected: assignment.runner.name,
|
|
95
|
+
actual: expectedRunner,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (profile &&
|
|
99
|
+
assignment.runner?.profile &&
|
|
100
|
+
assignment.runner.profile !== profile) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: 'profile_contract_mismatch',
|
|
104
|
+
checks,
|
|
105
|
+
expected: assignment.runner.profile,
|
|
106
|
+
actual: profile,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (profile &&
|
|
110
|
+
!assignment.runner?.profile &&
|
|
111
|
+
assignment.runner?.mode === 'review-only' &&
|
|
112
|
+
profile !== 'reviewer') {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
reason: 'profile_contract_mismatch',
|
|
116
|
+
checks,
|
|
117
|
+
expected: 'reviewer',
|
|
118
|
+
actual: profile,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// 1c. A `first` launch that lets the CLI choose the native identity (Codex) cannot be
|
|
122
|
+
// justified by a card observed for another session: that path must be a discovery run.
|
|
123
|
+
const preBoundIdentity = PRE_BOUND_IDENTITY[harness ?? assignment.assignee.harness] === false
|
|
124
|
+
? null
|
|
125
|
+
: assignment.assignee.session_id;
|
|
126
|
+
if (launchKind === 'first' && !preBoundIdentity && !discovery) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
reason: 'new_session_requires_discovery',
|
|
130
|
+
checks,
|
|
131
|
+
detail: 'this harness chooses the native session id itself, so its first launch must be a discovery run and the work dispatch must continue the discovered identity',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// 2. the request must be internally consistent about starting versus continuing
|
|
135
|
+
if (launchKind === 'resume' && !selector) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
reason: 'resume_selector_required',
|
|
139
|
+
checks,
|
|
140
|
+
detail: 'a resume launch must name the recorded native session',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (launchKind === 'first' && selector) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
reason: 'unexpected_resume_selector',
|
|
147
|
+
checks,
|
|
148
|
+
detail: 'a first launch must not carry a resume selector',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// 3. identity of the execution context
|
|
152
|
+
if (!path.isAbsolute(taskRoot ?? ''))
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
reason: 'task_root_required',
|
|
156
|
+
checks,
|
|
157
|
+
detail: 'the launcher must verify the collaboration record before dispatch',
|
|
158
|
+
};
|
|
159
|
+
if (fs.realpathSync(assignment.assignee.cwd) !== fs.realpathSync(cwd)) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
reason: 'cwd_mismatch',
|
|
163
|
+
checks,
|
|
164
|
+
detail: `launcher cwd ${cwd} is not the assigned cwd ${assignment.assignee.cwd}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (sessionId && assignment.assignee.session_id !== sessionId) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: 'session_mismatch',
|
|
171
|
+
checks,
|
|
172
|
+
detail: `launcher session ${sessionId} is not the assigned session ${assignment.assignee.session_id}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const record = readRecord(taskRoot);
|
|
176
|
+
if (!record)
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
reason: 'task_record_missing',
|
|
180
|
+
checks,
|
|
181
|
+
detail: taskRoot,
|
|
182
|
+
};
|
|
183
|
+
if (record.task_id !== assignment.task_id)
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
reason: 'task_mismatch',
|
|
187
|
+
checks,
|
|
188
|
+
detail: `${record.task_id} != ${assignment.task_id}`,
|
|
189
|
+
};
|
|
190
|
+
if (record.dispatch_frozen)
|
|
191
|
+
return { ok: false, reason: 'dispatch_frozen', checks };
|
|
192
|
+
const currentEpoch = record.coordinator?.owner_epoch ?? null;
|
|
193
|
+
const assignmentEpoch = assignment.coordinator?.owner_epoch ?? null;
|
|
194
|
+
if (currentEpoch !== null &&
|
|
195
|
+
assignmentEpoch !== null &&
|
|
196
|
+
currentEpoch !== assignmentEpoch) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
reason: 'stale_epoch',
|
|
200
|
+
checks,
|
|
201
|
+
expected: currentEpoch,
|
|
202
|
+
actual: assignmentEpoch,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// 4. the attempt must exist and be the one this contract was registered for
|
|
206
|
+
const attempt = findAttempt(record, assignment.assignment_id, assignment.attempt);
|
|
207
|
+
if (!attempt)
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
reason: 'attempt_not_registered',
|
|
211
|
+
checks,
|
|
212
|
+
detail: `${assignment.assignment_id}#${assignment.attempt}`,
|
|
213
|
+
};
|
|
214
|
+
if (attempt.assignee &&
|
|
215
|
+
(attempt.assignee.agent_id !== assignment.assignee.agent_id ||
|
|
216
|
+
attempt.assignee.session_id !== assignment.assignee.session_id)) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
reason: 'assignee_mismatch',
|
|
220
|
+
checks,
|
|
221
|
+
expected: attempt.assignee,
|
|
222
|
+
actual: assignment.assignee,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const liveAssignmentDigest = digestFile(assignmentFile);
|
|
226
|
+
if (attempt.assignment_digest &&
|
|
227
|
+
attempt.assignment_digest !== liveAssignmentDigest) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
reason: 'assignment_digest_mismatch',
|
|
231
|
+
checks,
|
|
232
|
+
expected: attempt.assignment_digest,
|
|
233
|
+
actual: liveAssignmentDigest,
|
|
234
|
+
detail: 'the assignment differs from the one registered for this attempt',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (attempt.assignment_ref &&
|
|
238
|
+
path.resolve(attempt.assignment_ref) !== path.resolve(assignmentFile)) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
reason: 'assignment_ref_mismatch',
|
|
242
|
+
checks,
|
|
243
|
+
expected: attempt.assignment_ref,
|
|
244
|
+
actual: assignmentFile,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const liveInputDigest = assignment.inputs?.[0]
|
|
248
|
+
? digestFile(assignment.inputs[0].ref)
|
|
249
|
+
: null;
|
|
250
|
+
if (attempt.input_revision &&
|
|
251
|
+
liveInputDigest &&
|
|
252
|
+
attempt.input_revision !== liveInputDigest) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
reason: 'input_revision_mismatch',
|
|
256
|
+
checks,
|
|
257
|
+
expected: attempt.input_revision,
|
|
258
|
+
actual: liveInputDigest,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// 5. capability readiness, or the restricted discovery path
|
|
262
|
+
if (discovery) {
|
|
263
|
+
const requiredSkills = (assignment.required?.skills ?? []).length;
|
|
264
|
+
const requiredTools = (assignment.required?.tools ?? []).length;
|
|
265
|
+
if (requiredSkills || requiredTools) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
reason: 'discovery_requires_no_capabilities',
|
|
269
|
+
checks,
|
|
270
|
+
required_skills: requiredSkills,
|
|
271
|
+
required_tools: requiredTools,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
checks.push({
|
|
275
|
+
check: 'preflight',
|
|
276
|
+
status: 'skipped',
|
|
277
|
+
detail: 'discovery mode requires no capability',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const result = preflight(assignment, card, { now, maxAgeMinutes });
|
|
282
|
+
checks.push({
|
|
283
|
+
check: 'preflight',
|
|
284
|
+
status: result.status,
|
|
285
|
+
blockers: result.blockers,
|
|
286
|
+
warnings: result.warnings,
|
|
287
|
+
});
|
|
288
|
+
if (result.status !== 'ready')
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
reason: 'capability_preflight_not_ready',
|
|
292
|
+
checks,
|
|
293
|
+
preflight: result,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (card.session_id !== assignment.assignee.session_id ||
|
|
297
|
+
(card.cwd !== undefined && !samePath(card.cwd, cwd))) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
reason: 'capabilities_identity_mismatch',
|
|
301
|
+
checks,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// 6. may this attempt start at all
|
|
305
|
+
if (['completed', 'failed', 'cancelled'].includes(attempt.state)) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
reason: 'attempt_already_closed',
|
|
309
|
+
checks,
|
|
310
|
+
state: attempt.state,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (attempt.state === 'needs_input' && attempt.awaiting === 'approval') {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
reason: 'awaiting_approval',
|
|
317
|
+
checks,
|
|
318
|
+
detail: 'an approval block is answered by the user, not by a new dispatch',
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (hasPendingCancellation(record, assignment.assignment_id, assignment.attempt)) {
|
|
322
|
+
return {
|
|
323
|
+
ok: false,
|
|
324
|
+
reason: 'cancel_unconfirmed',
|
|
325
|
+
checks,
|
|
326
|
+
detail: 'native stop must be reconciled before any new launch or continuation',
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const observation = attempt.native?.observation ?? 'unknown';
|
|
330
|
+
if (attempt.state === 'unknown' ||
|
|
331
|
+
(attempt.state === 'running' && observation !== 'confirmed')) {
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
reason: 'attempt_state_unknown',
|
|
335
|
+
checks,
|
|
336
|
+
detail: 'the native state of this attempt is unconfirmed; reconcile it before dispatching anything',
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (attempt.state === 'running' && launchKind !== 'resume') {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
reason: 'attempt_already_running',
|
|
343
|
+
checks,
|
|
344
|
+
detail: 'a running attempt may only be continued with --launch-kind resume',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (launchKind === 'first' && attempt.native?.run_id) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: 'already_launched',
|
|
351
|
+
checks,
|
|
352
|
+
detail: `attempt already has native run ${attempt.native.run_id}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
// A native session is the instance, not the attempt: the run discovered by an earlier,
|
|
356
|
+
// closed attempt of the same subtask is a legitimate continuation target. That is what makes
|
|
357
|
+
// "discovery → work" a supported sequence instead of a manual state edit.
|
|
358
|
+
const resumable = [
|
|
359
|
+
{
|
|
360
|
+
attempt: attempt.attempt,
|
|
361
|
+
session_id: attempt.native?.session_id ?? null,
|
|
362
|
+
run_id: attempt.native?.run_id ?? null,
|
|
363
|
+
},
|
|
364
|
+
...(record.attempts ?? [])
|
|
365
|
+
.filter((item) => item.subtask_id === assignment.assignment_id &&
|
|
366
|
+
item.attempt !== assignment.attempt &&
|
|
367
|
+
['completed', 'failed', 'cancelled'].includes(item.state))
|
|
368
|
+
.map((item) => ({
|
|
369
|
+
attempt: item.attempt,
|
|
370
|
+
session_id: item.native?.session_id ?? null,
|
|
371
|
+
run_id: item.native?.run_id ?? null,
|
|
372
|
+
})),
|
|
373
|
+
].filter((item) => item.session_id || item.run_id);
|
|
374
|
+
if (launchKind === 'resume' && !resumable.length) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
reason: 'nothing_to_resume',
|
|
378
|
+
checks,
|
|
379
|
+
detail: 'neither this attempt nor a closed earlier attempt of the subtask has a recorded native run',
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (launchKind !== 'resume' &&
|
|
383
|
+
!['assigned', 'accepted'].includes(attempt.state)) {
|
|
384
|
+
return {
|
|
385
|
+
ok: false,
|
|
386
|
+
reason: 'attempt_not_startable',
|
|
387
|
+
checks,
|
|
388
|
+
state: attempt.state,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
if (selector) {
|
|
392
|
+
const recorded = [
|
|
393
|
+
...new Set(resumable
|
|
394
|
+
.flatMap((item) => [item.session_id, item.run_id])
|
|
395
|
+
.filter(Boolean)),
|
|
396
|
+
];
|
|
397
|
+
if (!recorded.includes(selector)) {
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
reason: 'resume_selector_mismatch',
|
|
401
|
+
checks,
|
|
402
|
+
expected: recorded,
|
|
403
|
+
actual: selector,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// 7. the resources the contract asks for must actually be held by this assignee
|
|
408
|
+
const claims = (record.claims ?? []).filter((item) => !item.released_at);
|
|
409
|
+
const resourceChecks = [];
|
|
410
|
+
for (const resource of assignment.resources ?? []) {
|
|
411
|
+
const descriptor = describeResource(describeRequest(resource));
|
|
412
|
+
// Containment, not symmetric overlap: holding a child file does not grant the parent
|
|
413
|
+
// directory, while holding the directory does grant everything inside it.
|
|
414
|
+
const covering = claims.filter((item) => claimCovers(item, descriptor));
|
|
415
|
+
if (resource.access === 'write') {
|
|
416
|
+
const held = covering.find((item) => item.access === 'write' &&
|
|
417
|
+
item.holder?.agent_id === assignment.assignee.agent_id &&
|
|
418
|
+
item.holder?.session_id === assignment.assignee.session_id &&
|
|
419
|
+
(item.subtask_id ?? null) === assignment.assignment_id &&
|
|
420
|
+
(item.attempt ?? null) === assignment.attempt);
|
|
421
|
+
if (!held) {
|
|
422
|
+
resourceChecks.push({
|
|
423
|
+
resource: descriptor.key,
|
|
424
|
+
access: 'write',
|
|
425
|
+
status: 'not_held',
|
|
426
|
+
});
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
reason: 'resource_not_held',
|
|
430
|
+
checks,
|
|
431
|
+
resources: resourceChecks,
|
|
432
|
+
detail: `no active write claim covering ${descriptor.key} held by ${assignment.assignee.agent_id}/${assignment.assignee.session_id} for ${assignment.assignment_id}#${assignment.attempt}`,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (held.resource_epoch !== resource.resource_epoch) {
|
|
436
|
+
resourceChecks.push({
|
|
437
|
+
resource: descriptor.key,
|
|
438
|
+
access: 'write',
|
|
439
|
+
status: 'epoch_mismatch',
|
|
440
|
+
expected: resource.resource_epoch,
|
|
441
|
+
actual: held.resource_epoch,
|
|
442
|
+
});
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
reason: 'resource_epoch_mismatch',
|
|
446
|
+
checks,
|
|
447
|
+
resources: resourceChecks,
|
|
448
|
+
expected: resource.resource_epoch,
|
|
449
|
+
actual: held.resource_epoch,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
resourceChecks.push({
|
|
453
|
+
resource: descriptor.key,
|
|
454
|
+
access: 'write',
|
|
455
|
+
status: 'held',
|
|
456
|
+
resource_epoch: held.resource_epoch,
|
|
457
|
+
claim_id: held.claim_id,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
// Reading overlaps symmetrically: a writer holding a child file conflicts with a reader of
|
|
462
|
+
// the whole directory, so the containment rule is only for issuing write authority.
|
|
463
|
+
const blocker = claims
|
|
464
|
+
.filter((item) => claimsOverlap(item, descriptor))
|
|
465
|
+
.find((item) => item.access === 'write' &&
|
|
466
|
+
!(item.holder?.agent_id === assignment.assignee.agent_id &&
|
|
467
|
+
item.holder?.session_id === assignment.assignee.session_id));
|
|
468
|
+
if (blocker) {
|
|
469
|
+
resourceChecks.push({
|
|
470
|
+
resource: descriptor.key,
|
|
471
|
+
access: 'read',
|
|
472
|
+
status: 'write_held_by_other',
|
|
473
|
+
held_by: blocker.holder,
|
|
474
|
+
});
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
reason: 'resource_write_held_by_other',
|
|
478
|
+
checks,
|
|
479
|
+
resources: resourceChecks,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
resourceChecks.push({
|
|
483
|
+
resource: descriptor.key,
|
|
484
|
+
access: 'read',
|
|
485
|
+
status: 'clear',
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
checks.push({ check: 'resources', status: 'ok', resources: resourceChecks });
|
|
490
|
+
checks.push({
|
|
491
|
+
check: 'record',
|
|
492
|
+
status: 'ok',
|
|
493
|
+
state_revision: record.state_revision,
|
|
494
|
+
owner_epoch: currentEpoch,
|
|
495
|
+
attempt_state: attempt.state,
|
|
496
|
+
launch_kind: launchKind,
|
|
497
|
+
mode: discovery ? 'discovery' : 'work',
|
|
498
|
+
});
|
|
499
|
+
return {
|
|
500
|
+
ok: true,
|
|
501
|
+
checks,
|
|
502
|
+
state_revision: record.state_revision,
|
|
503
|
+
owner_epoch: currentEpoch,
|
|
504
|
+
attempt_state: attempt.state,
|
|
505
|
+
resources: resourceChecks,
|
|
506
|
+
mode: discovery ? 'discovery' : 'work',
|
|
507
|
+
launch_kind: launchKind,
|
|
508
|
+
resume_selector: selector,
|
|
509
|
+
pre_bound_identity: preBoundIdentity,
|
|
510
|
+
expected_identity: launchKind === 'resume' ? selector : preBoundIdentity,
|
|
511
|
+
capability_evidence: discovery ? 'declared' : 'runtime-bound',
|
|
512
|
+
assignment_digest: liveAssignmentDigest,
|
|
513
|
+
input_revision: liveInputDigest,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function samePath(left, right) {
|
|
517
|
+
try {
|
|
518
|
+
return fs.realpathSync(left) === fs.realpathSync(right);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** True when an existing claim covers the requested range (claim is equal to or above it). */
|
|
525
|
+
export function hasPendingCancellation(record, subtaskId, attempt) {
|
|
526
|
+
if ((record.uncertainty ?? []).some((item) => item.kind === 'cancel_unconfirmed' &&
|
|
527
|
+
!item.resolved_at &&
|
|
528
|
+
item.subtask_id === subtaskId &&
|
|
529
|
+
item.attempt === attempt))
|
|
530
|
+
return true;
|
|
531
|
+
const holder = findAttempt(record, subtaskId, attempt)?.assignee;
|
|
532
|
+
return (record.claims ?? []).some((claim) => !claim.released_at &&
|
|
533
|
+
claim.cancel_pending &&
|
|
534
|
+
holder &&
|
|
535
|
+
claim.holder?.agent_id === holder.agent_id &&
|
|
536
|
+
claim.holder?.session_id === holder.session_id &&
|
|
537
|
+
(claim.subtask_id == null || claim.subtask_id === subtaskId) &&
|
|
538
|
+
(claim.attempt == null || claim.attempt === attempt));
|
|
539
|
+
}
|
|
540
|
+
export function claimCovers(claim, requested) {
|
|
541
|
+
if (claim.key === requested.key)
|
|
542
|
+
return true;
|
|
543
|
+
if (!claim.path || !requested.path)
|
|
544
|
+
return false;
|
|
545
|
+
const relation = pathRelation(claim.path, requested.path);
|
|
546
|
+
return relation === 'same' || relation === 'ancestor';
|
|
547
|
+
}
|
|
548
|
+
function describeRequest(resource) {
|
|
549
|
+
return {
|
|
550
|
+
kind: resource.path ? 'path' : (resource.kind ?? 'path'),
|
|
551
|
+
path: resource.path,
|
|
552
|
+
...(resource.port === undefined ? {} : { port: resource.port }),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function digestFile(file) {
|
|
556
|
+
return `sha256:${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')}`;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Atomically reserve the launch. A second launcher cannot reserve the same attempt, and the
|
|
560
|
+
* reservation distinguishes a first launch from a continuation, so "already started" is a
|
|
561
|
+
* recorded fact rather than an assumption.
|
|
562
|
+
*/
|
|
563
|
+
export function reserveLaunch(taskRoot, { reservation, expectRevision = undefined, clock = Date.now(), }) {
|
|
564
|
+
const outcome = transact(taskRoot, {
|
|
565
|
+
actor: reservation.holder?.agent_id ?? 'launcher',
|
|
566
|
+
purpose: 'launch-reserve',
|
|
567
|
+
expectRevision,
|
|
568
|
+
clock,
|
|
569
|
+
apply: (record) => {
|
|
570
|
+
const attempt = findAttempt(record, reservation.subtask_id, reservation.attempt);
|
|
571
|
+
if (!attempt)
|
|
572
|
+
return { ok: false, reason: 'attempt_not_registered' };
|
|
573
|
+
const active = (record.launch_reservations ?? []).filter((item) => !item.released_at &&
|
|
574
|
+
item.subtask_id === reservation.subtask_id &&
|
|
575
|
+
item.attempt === reservation.attempt);
|
|
576
|
+
if (active.length) {
|
|
577
|
+
return {
|
|
578
|
+
ok: false,
|
|
579
|
+
reason: 'launch_already_reserved',
|
|
580
|
+
reservation: active[0],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
record.launch_reservations = record.launch_reservations ?? [];
|
|
584
|
+
const entry = {
|
|
585
|
+
...reservation,
|
|
586
|
+
reserved_at: nowIso(clock),
|
|
587
|
+
released_at: null,
|
|
588
|
+
outcome: null,
|
|
589
|
+
};
|
|
590
|
+
record.launch_reservations.push(entry);
|
|
591
|
+
return {
|
|
592
|
+
record,
|
|
593
|
+
op: 'launch-reserve',
|
|
594
|
+
summary: {
|
|
595
|
+
subtask_id: reservation.subtask_id,
|
|
596
|
+
attempt: reservation.attempt,
|
|
597
|
+
kind: reservation.kind,
|
|
598
|
+
},
|
|
599
|
+
result: { reservation_id: entry.reservation_id },
|
|
600
|
+
};
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
return outcome.ok
|
|
604
|
+
? {
|
|
605
|
+
ok: true,
|
|
606
|
+
reservation_id: outcome.result.reservation_id,
|
|
607
|
+
state_revision: outcome.state_revision,
|
|
608
|
+
}
|
|
609
|
+
: outcome;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Settle a launch reservation and persist what actually happened.
|
|
613
|
+
*
|
|
614
|
+
* Outcomes are deliberately distinguished:
|
|
615
|
+
* exited / exited_nonzero the process ran; the attempt keeps a recorded native run, so a
|
|
616
|
+
* later `first` launch is refused with `already_launched`
|
|
617
|
+
* timed_out / spawn_failed the process may be alive or may have started; the attempt moves to
|
|
618
|
+
* `unknown` with an uncertainty, so nothing may be dispatched over it
|
|
619
|
+
* until it is reconciled
|
|
620
|
+
* not_started nothing was spawned (a validation failure); the reservation is
|
|
621
|
+
* released and the attempt is left exactly as it was
|
|
622
|
+
*/
|
|
623
|
+
export function classifyIdentity({ ran, observed = null, expected = null, capabilityEvidence = null, }) {
|
|
624
|
+
const status = !ran
|
|
625
|
+
? 'not_observed'
|
|
626
|
+
: !observed
|
|
627
|
+
? 'unobserved'
|
|
628
|
+
: !expected
|
|
629
|
+
? 'discovered'
|
|
630
|
+
: observed === expected
|
|
631
|
+
? 'match'
|
|
632
|
+
: 'drift';
|
|
633
|
+
const confirmed = ran === true &&
|
|
634
|
+
status === 'match' &&
|
|
635
|
+
capabilityEvidence === 'runtime-bound';
|
|
636
|
+
return { status, confirmed };
|
|
637
|
+
}
|
|
638
|
+
export function settleLaunch(taskRoot, { reservationId, outcome, exitCode = null, sessionId = null, runId = null, evidenceRef = null, runner = null, detail = null, expectedIdentity = null, capabilityEvidence = null, identityStatus = null,
|
|
639
|
+
// A run is only "confirmed" when the process ended cleanly. `exited_nonzero` still records the
|
|
640
|
+
// attempted/started run and its honest exit, but the observation stays unconfirmed.
|
|
641
|
+
cleanExit = outcome === 'exited', clock = Date.now(), }) {
|
|
642
|
+
const notStarted = outcome === 'not_started';
|
|
643
|
+
const ran = outcome === 'exited' || outcome === 'exited_nonzero';
|
|
644
|
+
// Identity is decided before "did it run": a resumed session that comes back with a different
|
|
645
|
+
// id is a drift, and an identity that was never bound to the capability evidence cannot be
|
|
646
|
+
// promoted to `confirmed` no matter how cleanly the process exited.
|
|
647
|
+
const classified = classifyIdentity({
|
|
648
|
+
ran,
|
|
649
|
+
observed: sessionId,
|
|
650
|
+
expected: expectedIdentity,
|
|
651
|
+
capabilityEvidence,
|
|
652
|
+
});
|
|
653
|
+
const identityStatusResolved = identityStatus ?? classified.status;
|
|
654
|
+
const confirmed = ran &&
|
|
655
|
+
cleanExit === true &&
|
|
656
|
+
identityStatusResolved === 'match' &&
|
|
657
|
+
capabilityEvidence === 'runtime-bound';
|
|
658
|
+
const uncertain = outcome === 'timed_out' ||
|
|
659
|
+
outcome === 'spawn_failed' ||
|
|
660
|
+
outcome === 'evidence_write_failed' ||
|
|
661
|
+
identityStatusResolved === 'drift' ||
|
|
662
|
+
identityStatusResolved === 'unobserved' ||
|
|
663
|
+
(ran && !confirmed);
|
|
664
|
+
const settled = transact(taskRoot, {
|
|
665
|
+
actor: 'launcher',
|
|
666
|
+
purpose: 'launch-settle',
|
|
667
|
+
clock,
|
|
668
|
+
apply: (record) => {
|
|
669
|
+
const entry = (record.launch_reservations ?? []).find((item) => item.reservation_id === reservationId);
|
|
670
|
+
if (!entry)
|
|
671
|
+
return { ok: false, reason: 'unknown_reservation' };
|
|
672
|
+
if (entry.released_at)
|
|
673
|
+
return { ok: false, reason: 'reservation_already_settled' };
|
|
674
|
+
entry.released_at = nowIso(clock);
|
|
675
|
+
entry.outcome = outcome;
|
|
676
|
+
entry.exit_code = exitCode;
|
|
677
|
+
entry.session_id = sessionId;
|
|
678
|
+
entry.run_id = runId;
|
|
679
|
+
entry.detail = detail ?? null;
|
|
680
|
+
entry.identity_status = identityStatusResolved;
|
|
681
|
+
entry.expected_identity = expectedIdentity;
|
|
682
|
+
entry.observed_identity = sessionId;
|
|
683
|
+
if (notStarted)
|
|
684
|
+
return {
|
|
685
|
+
record,
|
|
686
|
+
op: 'launch-settle',
|
|
687
|
+
summary: { reservation_id: reservationId, outcome },
|
|
688
|
+
result: { outcome, attempt_unchanged: true },
|
|
689
|
+
};
|
|
690
|
+
const attempt = findAttempt(record, entry.subtask_id, entry.attempt);
|
|
691
|
+
if (!attempt)
|
|
692
|
+
return {
|
|
693
|
+
record,
|
|
694
|
+
op: 'launch-settle',
|
|
695
|
+
summary: { reservation_id: reservationId, outcome },
|
|
696
|
+
result: { outcome, attempt_missing: true },
|
|
697
|
+
};
|
|
698
|
+
const at = nowIso(clock);
|
|
699
|
+
// The native runner owns execution; the shared layer keeps the pointer and the fact
|
|
700
|
+
// that this attempt has been started at all.
|
|
701
|
+
attempt.native = {
|
|
702
|
+
runner: runner ?? attempt.native?.runner ?? null,
|
|
703
|
+
session_id: sessionId ?? attempt.native?.session_id ?? null,
|
|
704
|
+
run_id: runId ?? attempt.native?.run_id ?? null,
|
|
705
|
+
mission_id: attempt.native?.mission_id ?? null,
|
|
706
|
+
observation: confirmed ? 'confirmed' : 'unknown',
|
|
707
|
+
observed_at: at,
|
|
708
|
+
evidence_ref: evidenceRef ?? attempt.native?.evidence_ref ?? null,
|
|
709
|
+
manager: 'native',
|
|
710
|
+
launch_kind: entry.kind,
|
|
711
|
+
mode: entry.mode,
|
|
712
|
+
identity_status: identityStatusResolved,
|
|
713
|
+
};
|
|
714
|
+
attempt.updated_at = at;
|
|
715
|
+
attempt.transitions = attempt.transitions ?? [];
|
|
716
|
+
attempt.transitions.push({
|
|
717
|
+
from: attempt.state,
|
|
718
|
+
to: attempt.state,
|
|
719
|
+
at,
|
|
720
|
+
actor: 'launcher',
|
|
721
|
+
reason: `native launch settled: ${outcome}`,
|
|
722
|
+
evidence: evidenceRef,
|
|
723
|
+
});
|
|
724
|
+
if (ran && ['assigned', 'accepted'].includes(attempt.state))
|
|
725
|
+
attempt.state = 'running';
|
|
726
|
+
if (ran) {
|
|
727
|
+
// The process demonstrably started, so that question is answered even when the identity
|
|
728
|
+
// or the capability evidence is still unresolved.
|
|
729
|
+
const started = (record.uncertainty ?? []).find((item) => !item.resolved_at &&
|
|
730
|
+
item.kind === 'registered_not_started' &&
|
|
731
|
+
item.subtask_id === entry.subtask_id &&
|
|
732
|
+
item.attempt === entry.attempt);
|
|
733
|
+
if (started) {
|
|
734
|
+
started.resolved_at = at;
|
|
735
|
+
started.resolution = 'native launch observed';
|
|
736
|
+
started.resolution_evidence = evidenceRef;
|
|
737
|
+
started.resolved_by = 'launcher';
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (uncertain) {
|
|
741
|
+
// "May have started", "the identity drifted" and "it ran but nothing was recorded" are
|
|
742
|
+
// all reconcile-first states, never a free attempt.
|
|
743
|
+
if (!['completed', 'failed', 'cancelled'].includes(attempt.state))
|
|
744
|
+
attempt.state = 'unknown';
|
|
745
|
+
const kind = identityStatusResolved === 'drift'
|
|
746
|
+
? 'session_identity_drift'
|
|
747
|
+
: identityStatusResolved === 'unobserved'
|
|
748
|
+
? 'session_unobserved'
|
|
749
|
+
: ran && !confirmed
|
|
750
|
+
? 'session_unverified'
|
|
751
|
+
: outcome === 'timed_out'
|
|
752
|
+
? 'executed_unwritten'
|
|
753
|
+
: outcome === 'evidence_write_failed'
|
|
754
|
+
? 'executed_unwritten'
|
|
755
|
+
: 'started_unregistered';
|
|
756
|
+
const detailText = identityStatusResolved === 'unobserved'
|
|
757
|
+
? `launch ${entry.reservation_id} produced no readable native session identity; the requested identity ${entry.requested_session_id} is not evidence`
|
|
758
|
+
: identityStatusResolved === 'drift'
|
|
759
|
+
? `launch ${entry.reservation_id} came back as ${sessionId}, but ${expectedIdentity} was expected: the contract does not describe this instance`
|
|
760
|
+
: ran && !confirmed
|
|
761
|
+
? `launch ${entry.reservation_id} ran as native session ${sessionId}, but no runtime capability evidence covers that identity yet`
|
|
762
|
+
: outcome === 'evidence_write_failed'
|
|
763
|
+
? `launch ${entry.reservation_id} ran but its evidence could not be written (${detail}); the execution is not on record`
|
|
764
|
+
: `launch ${entry.reservation_id} ended as ${outcome}; the native process state is unconfirmed`;
|
|
765
|
+
openUncertainty(record, {
|
|
766
|
+
kind,
|
|
767
|
+
subtaskId: entry.subtask_id,
|
|
768
|
+
attempt: entry.attempt,
|
|
769
|
+
detail: detailText,
|
|
770
|
+
evidenceRef,
|
|
771
|
+
resolutionHint: identityStatusResolved === 'unobserved'
|
|
772
|
+
? 'inspect the native evidence: the session file or stream must expose the identity before it can be trusted'
|
|
773
|
+
: identityStatusResolved === 'drift'
|
|
774
|
+
? 'query the native runner, then register a contract that names the instance that actually ran'
|
|
775
|
+
: ran && !confirmed
|
|
776
|
+
? 'derive a capability card for this native session, then continue it with --launch-kind resume'
|
|
777
|
+
: 'query the native runner for this session/run and reconcile before any new dispatch',
|
|
778
|
+
clock,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
record,
|
|
783
|
+
op: 'launch-settle',
|
|
784
|
+
summary: { reservation_id: reservationId, outcome },
|
|
785
|
+
result: {
|
|
786
|
+
outcome,
|
|
787
|
+
state: attempt.state,
|
|
788
|
+
observation: attempt.native.observation,
|
|
789
|
+
identity_status: identityStatusResolved,
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
return settled.ok
|
|
795
|
+
? { ok: true, outcome, result: settled.result }
|
|
796
|
+
: settled;
|
|
797
|
+
}
|