@pentoshi/clai 3.9.1 → 3.9.3
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/dist/agent/compaction-attempt.d.ts +22 -0
- package/dist/agent/compaction-attempt.js +69 -0
- package/dist/agent/compaction-attempt.js.map +1 -0
- package/dist/agent/compaction-summary.d.ts +27 -0
- package/dist/agent/compaction-summary.js +62 -0
- package/dist/agent/compaction-summary.js.map +1 -1
- package/dist/agent/context-breakdown.d.ts +2 -0
- package/dist/agent/context-breakdown.js +37 -0
- package/dist/agent/context-breakdown.js.map +1 -1
- package/dist/agent/context-manager.d.ts +7 -1
- package/dist/agent/context-manager.js +64 -15
- package/dist/agent/context-manager.js.map +1 -1
- package/dist/agent/continue-orient.d.ts +8 -0
- package/dist/agent/continue-orient.js +26 -3
- package/dist/agent/continue-orient.js.map +1 -1
- package/dist/agent/durable-envelope.d.ts +39 -0
- package/dist/agent/durable-envelope.js +195 -0
- package/dist/agent/durable-envelope.js.map +1 -0
- package/dist/agent/events.d.ts +21 -0
- package/dist/agent/loop-guard.d.ts +13 -5
- package/dist/agent/loop-guard.js +114 -1
- package/dist/agent/loop-guard.js.map +1 -1
- package/dist/agent/outcomes.d.ts +29 -0
- package/dist/agent/outcomes.js +118 -3
- package/dist/agent/outcomes.js.map +1 -1
- package/dist/agent/plan-decision.js +8 -3
- package/dist/agent/plan-decision.js.map +1 -1
- package/dist/agent/plan-tool.d.ts +5 -0
- package/dist/agent/plan-tool.js +166 -6
- package/dist/agent/plan-tool.js.map +1 -1
- package/dist/agent/reliability-policy.d.ts +11 -6
- package/dist/agent/reliability-policy.js +19 -13
- package/dist/agent/reliability-policy.js.map +1 -1
- package/dist/agent/request-budget.d.ts +24 -0
- package/dist/agent/request-budget.js +59 -0
- package/dist/agent/request-budget.js.map +1 -0
- package/dist/agent/responder-context.js +2 -2
- package/dist/agent/responder-context.js.map +1 -1
- package/dist/agent/responder-parent.d.ts +30 -0
- package/dist/agent/responder-parent.js +82 -0
- package/dist/agent/responder-parent.js.map +1 -0
- package/dist/agent/runner.d.ts +14 -1
- package/dist/agent/runner.js +850 -237
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/scope-context.d.ts +5 -0
- package/dist/agent/scope-context.js +7 -1
- package/dist/agent/scope-context.js.map +1 -1
- package/dist/agent/session-policy.js +1 -0
- package/dist/agent/session-policy.js.map +1 -1
- package/dist/agent/stop-summary.js +13 -7
- package/dist/agent/stop-summary.js.map +1 -1
- package/dist/agent/stream-recovery.d.ts +83 -0
- package/dist/agent/stream-recovery.js +270 -0
- package/dist/agent/stream-recovery.js.map +1 -0
- package/dist/agent/task-analyzer.js +7 -3
- package/dist/agent/task-analyzer.js.map +1 -1
- package/dist/agent/task-evidence.js +2 -0
- package/dist/agent/task-evidence.js.map +1 -1
- package/dist/agent/task-sync.d.ts +11 -0
- package/dist/agent/task-sync.js +31 -0
- package/dist/agent/task-sync.js.map +1 -1
- package/dist/agent/tool-call-parser.d.ts +6 -13
- package/dist/agent/tool-call-parser.js +186 -82
- package/dist/agent/tool-call-parser.js.map +1 -1
- package/dist/agent/tool-history.d.ts +8 -2
- package/dist/agent/tool-history.js +8 -1
- package/dist/agent/tool-history.js.map +1 -1
- package/dist/agent/turn-outcome.d.ts +4 -1
- package/dist/agent/turn-outcome.js +16 -2
- package/dist/agent/turn-outcome.js.map +1 -1
- package/dist/app/adapters/agent-event-adapter.js +27 -0
- package/dist/app/adapters/agent-event-adapter.js.map +1 -1
- package/dist/app/adapters/current-agent-adapter.js +1 -0
- package/dist/app/adapters/current-agent-adapter.js.map +1 -1
- package/dist/app/adapters/current-jobs-adapter.js +4 -3
- package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
- package/dist/app/adapters/current-store-adapter.js +4 -3
- package/dist/app/adapters/current-store-adapter.js.map +1 -1
- package/dist/app/adapters/current-updates-adapter.d.ts +1 -1
- package/dist/app/adapters/current-updates-adapter.js +34 -5
- package/dist/app/adapters/current-updates-adapter.js.map +1 -1
- package/dist/app/adapters/quiet-meta-tools.js +2 -1
- package/dist/app/adapters/quiet-meta-tools.js.map +1 -1
- package/dist/app/controllers/job-controller.d.ts +4 -3
- package/dist/app/controllers/job-controller.js +9 -6
- package/dist/app/controllers/job-controller.js.map +1 -1
- package/dist/app/controllers/plan-controller.js +14 -0
- package/dist/app/controllers/plan-controller.js.map +1 -1
- package/dist/app/controllers/responder-settlement.js +2 -2
- package/dist/app/controllers/responder-settlement.js.map +1 -1
- package/dist/app/controllers/session-compact-helper.d.ts +23 -1
- package/dist/app/controllers/session-compact-helper.js +117 -7
- package/dist/app/controllers/session-compact-helper.js.map +1 -1
- package/dist/app/controllers/session-context-usage.d.ts +24 -0
- package/dist/app/controllers/session-context-usage.js +89 -0
- package/dist/app/controllers/session-context-usage.js.map +1 -0
- package/dist/app/controllers/session-controller.d.ts +18 -17
- package/dist/app/controllers/session-controller.js +127 -119
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/app/controllers/session-prompt-queue.d.ts +3 -0
- package/dist/app/controllers/session-prompt-queue.js +20 -1
- package/dist/app/controllers/session-prompt-queue.js.map +1 -1
- package/dist/app/controllers/session-responder.js +16 -6
- package/dist/app/controllers/session-responder.js.map +1 -1
- package/dist/app/controllers/session-turn-request.d.ts +18 -0
- package/dist/app/controllers/session-turn-request.js +31 -0
- package/dist/app/controllers/session-turn-request.js.map +1 -0
- package/dist/app/controllers/turn-continuation.d.ts +14 -0
- package/dist/app/controllers/turn-continuation.js +46 -0
- package/dist/app/controllers/turn-continuation.js.map +1 -0
- package/dist/app/events/app-event.d.ts +21 -0
- package/dist/app/events/app-event.js +1 -0
- package/dist/app/events/app-event.js.map +1 -1
- package/dist/app/ports/agent-port.d.ts +7 -0
- package/dist/app/ports/jobs-port.d.ts +4 -3
- package/dist/app/ports/persistence-port.d.ts +11 -0
- package/dist/app/ports/updates-port.d.ts +8 -0
- package/dist/attachments/service.js +13 -2
- package/dist/attachments/service.js.map +1 -1
- package/dist/commands/providers.js +13 -10
- package/dist/commands/providers.js.map +1 -1
- package/dist/commands/update.d.ts +4 -0
- package/dist/commands/update.js +14 -0
- package/dist/commands/update.js.map +1 -1
- package/dist/llm/adapters/anthropic-tools.d.ts +14 -1
- package/dist/llm/adapters/anthropic-tools.js +36 -5
- package/dist/llm/adapters/anthropic-tools.js.map +1 -1
- package/dist/llm/adapters/gemini-tools.d.ts +3 -1
- package/dist/llm/adapters/gemini-tools.js +5 -2
- package/dist/llm/adapters/gemini-tools.js.map +1 -1
- package/dist/llm/adapters/ollama-tools.js +8 -3
- package/dist/llm/adapters/ollama-tools.js.map +1 -1
- package/dist/llm/adapters/openai-tools.js +8 -1
- package/dist/llm/adapters/openai-tools.js.map +1 -1
- package/dist/llm/agentrouter.js +2 -0
- package/dist/llm/agentrouter.js.map +1 -1
- package/dist/llm/anthropic.d.ts +8 -0
- package/dist/llm/anthropic.js +68 -9
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/aws-mantle.js +60 -12
- package/dist/llm/aws-mantle.js.map +1 -1
- package/dist/llm/bynara.js +2 -0
- package/dist/llm/bynara.js.map +1 -1
- package/dist/llm/capabilities.d.ts +7 -0
- package/dist/llm/capabilities.js +22 -0
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/gemini.d.ts +3 -0
- package/dist/llm/gemini.js +71 -11
- package/dist/llm/gemini.js.map +1 -1
- package/dist/llm/groq.d.ts +20 -1
- package/dist/llm/groq.js +32 -4
- package/dist/llm/groq.js.map +1 -1
- package/dist/llm/http.d.ts +29 -0
- package/dist/llm/http.js +122 -29
- package/dist/llm/http.js.map +1 -1
- package/dist/llm/kimchi.js +2 -0
- package/dist/llm/kimchi.js.map +1 -1
- package/dist/llm/nvidia.js +2 -0
- package/dist/llm/nvidia.js.map +1 -1
- package/dist/llm/ollama.d.ts +5 -0
- package/dist/llm/ollama.js +53 -6
- package/dist/llm/ollama.js.map +1 -1
- package/dist/llm/openai.js +2 -0
- package/dist/llm/openai.js.map +1 -1
- package/dist/llm/openrouter.js +2 -0
- package/dist/llm/openrouter.js.map +1 -1
- package/dist/llm/qwen-cloud.js +2 -0
- package/dist/llm/qwen-cloud.js.map +1 -1
- package/dist/llm/router.d.ts +16 -0
- package/dist/llm/router.js +79 -23
- package/dist/llm/router.js.map +1 -1
- package/dist/llm/sampling.d.ts +32 -0
- package/dist/llm/sampling.js +48 -0
- package/dist/llm/sampling.js.map +1 -0
- package/dist/llm/stream-progress.d.ts +4 -0
- package/dist/llm/stream-progress.js +43 -0
- package/dist/llm/stream-progress.js.map +1 -0
- package/dist/llm/system-messages.d.ts +29 -0
- package/dist/llm/system-messages.js +51 -0
- package/dist/llm/system-messages.js.map +1 -0
- package/dist/llm/token-usage.d.ts +4 -1
- package/dist/llm/token-usage.js +56 -4
- package/dist/llm/token-usage.js.map +1 -1
- package/dist/llm/tool-protocol.js +30 -0
- package/dist/llm/tool-protocol.js.map +1 -1
- package/dist/os/process-tree.d.ts +15 -0
- package/dist/os/process-tree.js +77 -0
- package/dist/os/process-tree.js.map +1 -0
- package/dist/prompts/embedded.js +1 -1
- package/dist/prompts/embedded.js.map +1 -1
- package/dist/prompts/system.agent.md +13 -12
- package/dist/repl/prompt-line.d.ts +5 -0
- package/dist/repl/prompt-line.js +7 -1
- package/dist/repl/prompt-line.js.map +1 -1
- package/dist/repl.js +61 -41
- package/dist/repl.js.map +1 -1
- package/dist/safety/classifier.js +98 -44
- package/dist/safety/classifier.js.map +1 -1
- package/dist/safety/engagement-policy.d.ts +8 -0
- package/dist/safety/engagement-policy.js +52 -7
- package/dist/safety/engagement-policy.js.map +1 -1
- package/dist/safety/patterns.d.ts +44 -0
- package/dist/safety/patterns.js +168 -37
- package/dist/safety/patterns.js.map +1 -1
- package/dist/store/config.d.ts +9 -1
- package/dist/store/config.js +51 -1
- package/dist/store/config.js.map +1 -1
- package/dist/store/history-index.d.ts +12 -0
- package/dist/store/history-index.js +47 -18
- package/dist/store/history-index.js.map +1 -1
- package/dist/store/history.d.ts +5 -2
- package/dist/store/history.js +153 -62
- package/dist/store/history.js.map +1 -1
- package/dist/store/plan.d.ts +81 -5
- package/dist/store/plan.js +406 -46
- package/dist/store/plan.js.map +1 -1
- package/dist/store/redaction-cache.d.ts +12 -0
- package/dist/store/redaction-cache.js +51 -0
- package/dist/store/redaction-cache.js.map +1 -0
- package/dist/store/responder-settlement.d.ts +5 -1
- package/dist/store/responder-settlement.js +42 -30
- package/dist/store/responder-settlement.js.map +1 -1
- package/dist/store/scope.js +36 -27
- package/dist/store/scope.js.map +1 -1
- package/dist/store/task-transitions.d.ts +19 -0
- package/dist/store/task-transitions.js +46 -0
- package/dist/store/task-transitions.js.map +1 -0
- package/dist/tools/capabilities.js +33 -18
- package/dist/tools/capabilities.js.map +1 -1
- package/dist/tools/command-intent.d.ts +24 -0
- package/dist/tools/command-intent.js +158 -4
- package/dist/tools/command-intent.js.map +1 -1
- package/dist/tools/definitions.d.ts +5 -1
- package/dist/tools/definitions.js +162 -21
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/fs.d.ts +16 -0
- package/dist/tools/fs.js +122 -46
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/image.js +1 -1
- package/dist/tools/image.js.map +1 -1
- package/dist/tools/jobs.d.ts +78 -4
- package/dist/tools/jobs.js +542 -115
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/net-ping-sweep.d.ts +3 -1
- package/dist/tools/net-ping-sweep.js +64 -26
- package/dist/tools/net-ping-sweep.js.map +1 -1
- package/dist/tools/nmap-runner.js +5 -2
- package/dist/tools/nmap-runner.js.map +1 -1
- package/dist/tools/pdf.js +50 -7
- package/dist/tools/pdf.js.map +1 -1
- package/dist/tools/registry.js +154 -96
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shell.d.ts +24 -2
- package/dist/tools/shell.js +113 -28
- package/dist/tools/shell.js.map +1 -1
- package/dist/tools/tool-types.d.ts +2 -0
- package/dist/tools/validate.d.ts +8 -5
- package/dist/tools/validate.js +177 -7
- package/dist/tools/validate.js.map +1 -1
- package/dist/tools/wordlists.js +69 -17
- package/dist/tools/wordlists.js.map +1 -1
- package/dist/tui/state.d.ts +4 -0
- package/dist/tui/state.js +88 -2
- package/dist/tui/state.js.map +1 -1
- package/dist/tui-v2/actions/keymap.js +3 -8
- package/dist/tui-v2/actions/keymap.js.map +1 -1
- package/dist/tui-v2/app/App.js +56 -5
- package/dist/tui-v2/app/App.js.map +1 -1
- package/dist/tui-v2/app/commands/config-commands.js +5 -2
- package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
- package/dist/tui-v2/app/commands/picker-commands.js +3 -0
- package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
- package/dist/tui-v2/app/plan-lifecycle.d.ts +1 -1
- package/dist/tui-v2/app/plan-lifecycle.js +2 -1
- package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
- package/dist/tui-v2/components/composer/composer-input-box.d.ts +1 -1
- package/dist/tui-v2/components/composer/composer-input-box.js +12 -3
- package/dist/tui-v2/components/composer/composer-input-box.js.map +1 -1
- package/dist/tui-v2/components/jobs/jobs-panel.js +19 -6
- package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
- package/dist/tui-v2/components/plan/plan-view.js +9 -6
- package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
- package/dist/tui-v2/components/status/status-line.d.ts +15 -0
- package/dist/tui-v2/components/status/status-line.js +33 -25
- package/dist/tui-v2/components/status/status-line.js.map +1 -1
- package/dist/tui-v2/components/transcript/assistant-message.js +17 -9
- package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
- package/dist/tui-v2/components/transcript/compacted-row.js +49 -22
- package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
- package/dist/tui-v2/components/transcript/intro-card.js +3 -0
- package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
- package/dist/tui-v2/components/transcript/thinking-block.js +2 -1
- package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-view.js +27 -2
- package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
- package/dist/tui-v2/components/transcript/use-transcript-selection.js +57 -6
- package/dist/tui-v2/components/transcript/use-transcript-selection.js.map +1 -1
- package/dist/tui-v2/composer/composer-editor.js +20 -12
- package/dist/tui-v2/composer/composer-editor.js.map +1 -1
- package/dist/tui-v2/rendering/incremental-strip.d.ts +11 -0
- package/dist/tui-v2/rendering/incremental-strip.js +89 -0
- package/dist/tui-v2/rendering/incremental-strip.js.map +1 -0
- package/dist/tui-v2/rendering/plan-view.d.ts +12 -0
- package/dist/tui-v2/rendering/plan-view.js +71 -2
- package/dist/tui-v2/rendering/plan-view.js.map +1 -1
- package/dist/tui-v2/rendering/streaming-markdown.d.ts +25 -0
- package/dist/tui-v2/rendering/streaming-markdown.js +108 -0
- package/dist/tui-v2/rendering/streaming-markdown.js.map +1 -0
- package/dist/tui-v2/rendering/thinking-tail.d.ts +5 -0
- package/dist/tui-v2/rendering/thinking-tail.js +26 -0
- package/dist/tui-v2/rendering/thinking-tail.js.map +1 -0
- package/dist/tui-v2/state/transcript-hydrate.js +6 -2
- package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
- package/dist/tui-v2/state/transcript-reducer.js +146 -25
- package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
- package/dist/tui-v2/state/transcript-types.d.ts +6 -0
- package/dist/tui-v2/state/transcript-types.js +17 -0
- package/dist/tui-v2/state/transcript-types.js.map +1 -1
- package/dist/types.d.ts +29 -0
- package/dist/types.js.map +1 -1
- package/dist/ui/intro-header.d.ts +1 -0
- package/dist/ui/intro-header.js +10 -13
- package/dist/ui/intro-header.js.map +1 -1
- package/dist/ui/markdown.js +9 -2
- package/dist/ui/markdown.js.map +1 -1
- package/dist/ui/mentions.d.ts +5 -0
- package/dist/ui/mentions.js +66 -7
- package/dist/ui/mentions.js.map +1 -1
- package/dist/ui/thinking.d.ts +5 -1
- package/dist/ui/thinking.js +3 -2
- package/dist/ui/thinking.js.map +1 -1
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- package/package.json +2 -1
package/dist/tools/jobs.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { spawn, execFileSync } from "node:child_process";
|
|
2
|
-
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { mkdir, open, stat } from "node:fs/promises";
|
|
4
|
-
import { basename, join } from "node:path";
|
|
4
|
+
import { basename, join, sep } from "node:path";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import { StringDecoder } from "node:string_decoder";
|
|
7
7
|
import { redactSecrets } from "../llm/provider.js";
|
|
8
8
|
import { safeCwd } from "../os/cwd.js";
|
|
9
9
|
import { getJobsDir } from "../store/paths.js";
|
|
10
10
|
import { resolveShell } from "./shell.js";
|
|
11
|
+
import { terminateProcessTree } from "../os/process-tree.js";
|
|
11
12
|
export function formatJobElapsed(job, now = Date.now()) {
|
|
12
13
|
const startedAt = Date.parse(job.startedAt);
|
|
13
14
|
const endedAt = job.endedAt ? Date.parse(job.endedAt) : now;
|
|
@@ -31,6 +32,15 @@ function commandDisplay(command) {
|
|
|
31
32
|
return command;
|
|
32
33
|
return command.display ?? [command.command, ...command.argv].map(displayArg).join(" ");
|
|
33
34
|
}
|
|
35
|
+
function launchFollowUp(id, responder) {
|
|
36
|
+
return responder
|
|
37
|
+
? "Responder owns completion tracking for this self-completing job. " +
|
|
38
|
+
"Do not poll it with shell.tail/shell.jobs, sleep, or read its artifact. " +
|
|
39
|
+
"Continue other work; if not already delivered, its terminal result will be delivered automatically."
|
|
40
|
+
: "OS launch does not prove application readiness or continued liveness. " +
|
|
41
|
+
`This is a normal background job: use shell.tail {"id":"${id}"} with nextOffset and shell.jobs for status; ` +
|
|
42
|
+
"for a server or watcher, also run an application readiness probe. Do not launch a duplicate.";
|
|
43
|
+
}
|
|
34
44
|
const PER_FILE_BYTES = 1024 * 1024;
|
|
35
45
|
const MAX_STREAM_BYTES = 16 * 1024 * 1024;
|
|
36
46
|
const DEFAULT_TAIL_BYTES = 8_000;
|
|
@@ -42,10 +52,16 @@ const MAX_DURABLE_TERMINAL_JOBS = 80;
|
|
|
42
52
|
const TERMINAL_JOB_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
|
43
53
|
const ARCHIVED_UNSETTLED_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
44
54
|
const MAX_ARCHIVED_UNSETTLED_NOTIFICATIONS = 40;
|
|
55
|
+
const MAX_SUPERSEDED_REVISIONS = 5;
|
|
56
|
+
const SETTLEMENT_MAX_BACKOFF_MS = 30_000;
|
|
57
|
+
const LIVENESS_WATCH_INTERVAL_MS = 2_000;
|
|
58
|
+
const SETTLEMENT_DEAD_LETTER_MS = 10 * 60_000;
|
|
45
59
|
/** Max lines shell.jobs returns to the model (running first, then recent). */
|
|
46
60
|
const LIST_JOBS_MAX_LINES = 40;
|
|
47
61
|
/** Coalesce window for chatty stdout/stderr progress persistence + UI events. */
|
|
48
62
|
const PROGRESS_FLUSH_MS = 250;
|
|
63
|
+
/** Durable registry cadence for running-job progress (UI updates stay at 250ms). */
|
|
64
|
+
const DURABLE_PROGRESS_FLUSH_MS = 5_000;
|
|
49
65
|
/**
|
|
50
66
|
* A live job with no in-process ChildProcess handle (resumed session, or a
|
|
51
67
|
* handle that was released) is only declared "lost" after failing the liveness
|
|
@@ -55,6 +71,8 @@ const PROGRESS_FLUSH_MS = 250;
|
|
|
55
71
|
* elapsed, and a ✗ status=lost that later flipped back to exit=0).
|
|
56
72
|
*/
|
|
57
73
|
const LIVENESS_LOST_GRACE_MS = 8_000;
|
|
74
|
+
/** Minimum spacing between liveness probes for the same job. */
|
|
75
|
+
const LIVENESS_PROBE_INTERVAL_MS = 1_000;
|
|
58
76
|
/**
|
|
59
77
|
* Number of trailing bytes in `buf` that form an incomplete multi-byte UTF-8
|
|
60
78
|
* sequence (a lead byte whose continuation bytes were cut off by the read
|
|
@@ -91,27 +109,75 @@ function processAlive(pid) {
|
|
|
91
109
|
return error.code === "EPERM";
|
|
92
110
|
}
|
|
93
111
|
}
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
/** Identity is invariant for a pid's lifetime, so it caches safely. */
|
|
113
|
+
const PROCESS_IDENTITY_TTL_MS = 15_000;
|
|
114
|
+
const PROCESS_IDENTITY_CACHE_MAX = 512;
|
|
115
|
+
const processIdentityCache = new Map();
|
|
116
|
+
function readLinuxProcessStart(pid) {
|
|
117
|
+
try {
|
|
118
|
+
const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
119
|
+
const commEnd = raw.lastIndexOf(")");
|
|
120
|
+
if (commEnd < 0)
|
|
121
|
+
return undefined;
|
|
122
|
+
const fields = raw.slice(commEnd + 2).trim().split(/\s+/);
|
|
123
|
+
const startTime = fields[19];
|
|
124
|
+
return startTime && startTime.length > 0 ? startTime : undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
96
127
|
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function readPsProcessStart(pid) {
|
|
97
131
|
try {
|
|
98
|
-
// Start time only:
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
// (stop refused with "process identity no longer matches", liveness falsely
|
|
103
|
-
// marked lost, and double-Esc cancel unable to kill the process).
|
|
104
|
-
const value = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
132
|
+
// Start time only: the command line is deliberately excluded — `sh -c "cmd"`
|
|
133
|
+
// execs into `cmd`, mutating `ps command=` mid-run, which made a live job
|
|
134
|
+
// fail its OWN identity check.
|
|
135
|
+
return execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
105
136
|
encoding: "utf8",
|
|
106
137
|
timeout: 2_000,
|
|
107
138
|
stdio: ["ignore", "pipe", "ignore"],
|
|
108
139
|
}).trim();
|
|
109
|
-
return value ? createHash("sha256").update(value).digest("hex") : undefined;
|
|
110
140
|
}
|
|
111
141
|
catch {
|
|
112
142
|
return undefined;
|
|
113
143
|
}
|
|
114
144
|
}
|
|
145
|
+
function computeProcessIdentity(pid) {
|
|
146
|
+
const raw = process.platform === "linux"
|
|
147
|
+
? readLinuxProcessStart(pid)
|
|
148
|
+
: readPsProcessStart(pid);
|
|
149
|
+
return raw ? createHash("sha256").update(raw).digest("hex") : undefined;
|
|
150
|
+
}
|
|
151
|
+
function pruneProcessIdentityCache(now) {
|
|
152
|
+
if (processIdentityCache.size <= PROCESS_IDENTITY_CACHE_MAX)
|
|
153
|
+
return;
|
|
154
|
+
for (const [pid, entry] of processIdentityCache) {
|
|
155
|
+
if (now - entry.at >= PROCESS_IDENTITY_TTL_MS)
|
|
156
|
+
processIdentityCache.delete(pid);
|
|
157
|
+
}
|
|
158
|
+
if (processIdentityCache.size > PROCESS_IDENTITY_CACHE_MAX) {
|
|
159
|
+
processIdentityCache.clear();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function forgetProcessIdentity(pid) {
|
|
163
|
+
if (pid)
|
|
164
|
+
processIdentityCache.delete(pid);
|
|
165
|
+
}
|
|
166
|
+
function processIdentity(pid, options = {}) {
|
|
167
|
+
if (!pid || process.platform === "win32")
|
|
168
|
+
return undefined;
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const cached = processIdentityCache.get(pid);
|
|
171
|
+
if (cached &&
|
|
172
|
+
!options.refresh &&
|
|
173
|
+
now - cached.at < PROCESS_IDENTITY_TTL_MS) {
|
|
174
|
+
return cached.value;
|
|
175
|
+
}
|
|
176
|
+
const value = computeProcessIdentity(pid);
|
|
177
|
+
processIdentityCache.set(pid, { value, at: now });
|
|
178
|
+
pruneProcessIdentityCache(now);
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
115
181
|
/**
|
|
116
182
|
* Heuristic for legacy registry rows that were actually agent tool-stall
|
|
117
183
|
* trackers (commandDisplay = "fs.list /path", "shell.jobs {}", …), not real
|
|
@@ -268,7 +334,9 @@ export class JobManager {
|
|
|
268
334
|
authorizationTimers = new Map();
|
|
269
335
|
responderLeases = new Map();
|
|
270
336
|
settlementTimers = new Map();
|
|
271
|
-
|
|
337
|
+
pendingSettlements = new Map();
|
|
338
|
+
consumedResponderResults = new Map();
|
|
339
|
+
livenessWatchTimer;
|
|
272
340
|
finalizations = new Map();
|
|
273
341
|
listeners = new Set();
|
|
274
342
|
/**
|
|
@@ -278,6 +346,7 @@ export class JobManager {
|
|
|
278
346
|
* observed alive again or finalized.
|
|
279
347
|
*/
|
|
280
348
|
livenessMisses = new Map();
|
|
349
|
+
livenessCheckedAt = new Map();
|
|
281
350
|
registryRetryTimer;
|
|
282
351
|
registryPath;
|
|
283
352
|
transientV2RegistryPath;
|
|
@@ -286,10 +355,84 @@ export class JobManager {
|
|
|
286
355
|
this.registryPath = join(this.jobsDir, REGISTRY_FILE);
|
|
287
356
|
this.transientV2RegistryPath = join(this.jobsDir, TRANSIENT_V2_REGISTRY_FILE);
|
|
288
357
|
this.loadAndReconcile();
|
|
358
|
+
this.sweepOrphanArtifacts();
|
|
289
359
|
}
|
|
290
360
|
isDurable(job) {
|
|
291
361
|
return job.kind !== "ephemeral";
|
|
292
362
|
}
|
|
363
|
+
artifactPathsOf(job) {
|
|
364
|
+
return [
|
|
365
|
+
job.artifactPath,
|
|
366
|
+
job.stdoutArtifact,
|
|
367
|
+
job.stderrArtifact,
|
|
368
|
+
...(job.artifacts?.stdout.chunks ?? []),
|
|
369
|
+
...(job.artifacts?.stderr.chunks ?? []),
|
|
370
|
+
].filter((path) => Boolean(path));
|
|
371
|
+
}
|
|
372
|
+
/** Delete a dropped job's artifact chunks, never one still referenced. */
|
|
373
|
+
removeJobArtifacts(job) {
|
|
374
|
+
const paths = this.artifactPathsOf(job).filter((path) => path.startsWith(`${this.jobsDir}${sep}`));
|
|
375
|
+
if (paths.length === 0)
|
|
376
|
+
return;
|
|
377
|
+
const referenced = new Set();
|
|
378
|
+
for (const other of this.jobs.values()) {
|
|
379
|
+
for (const path of this.artifactPathsOf(other))
|
|
380
|
+
referenced.add(path);
|
|
381
|
+
}
|
|
382
|
+
for (const path of new Set(paths)) {
|
|
383
|
+
if (referenced.has(path))
|
|
384
|
+
continue;
|
|
385
|
+
try {
|
|
386
|
+
rmSync(path, { force: true });
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Artifact cleanup is best-effort; a locked file is retried on boot.
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/** Drop a job row plus its per-job caches and artifacts. */
|
|
394
|
+
forgetJob(id) {
|
|
395
|
+
const job = this.jobs.get(id);
|
|
396
|
+
this.jobs.delete(id);
|
|
397
|
+
for (const [notificationId, notification] of this.notifications) {
|
|
398
|
+
if (notification.jobId === id)
|
|
399
|
+
this.notifications.delete(notificationId);
|
|
400
|
+
}
|
|
401
|
+
this.consumedResponderResults.delete(id);
|
|
402
|
+
this.livenessMisses.delete(id);
|
|
403
|
+
this.livenessCheckedAt.delete(id);
|
|
404
|
+
if (!job)
|
|
405
|
+
return;
|
|
406
|
+
forgetProcessIdentity(job.pid);
|
|
407
|
+
this.removeJobArtifacts(job);
|
|
408
|
+
}
|
|
409
|
+
/** Remove artifact files whose job row no longer exists. */
|
|
410
|
+
sweepOrphanArtifacts() {
|
|
411
|
+
let names;
|
|
412
|
+
try {
|
|
413
|
+
names = readdirSync(this.jobsDir);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const known = new Set(this.jobs.keys());
|
|
419
|
+
const now = Date.now();
|
|
420
|
+
for (const name of names) {
|
|
421
|
+
if (!/\.(stdout|stderr)\.log(\.\d+)?$/.test(name))
|
|
422
|
+
continue;
|
|
423
|
+
if ([...known].some((id) => name.includes(`-${id}.`)))
|
|
424
|
+
continue;
|
|
425
|
+
const path = join(this.jobsDir, name);
|
|
426
|
+
try {
|
|
427
|
+
if (now - statSync(path).mtimeMs <= TERMINAL_JOB_MAX_AGE_MS)
|
|
428
|
+
continue;
|
|
429
|
+
rmSync(path, { force: true });
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
// Ignore unreadable/locked leftovers.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
293
436
|
isLive(job) {
|
|
294
437
|
return (job.status === "running" ||
|
|
295
438
|
job.status === "starting" ||
|
|
@@ -355,18 +498,17 @@ export class JobManager {
|
|
|
355
498
|
if (!current || (leaseId && current !== leaseId))
|
|
356
499
|
return;
|
|
357
500
|
this.responderLeases.delete(sessionId);
|
|
358
|
-
const archivedAt = new Date().toISOString();
|
|
359
501
|
let changed = false;
|
|
360
502
|
for (const notification of this.notifications.values()) {
|
|
361
|
-
if (notification.ownerSessionId
|
|
362
|
-
notification.responderLeaseId
|
|
363
|
-
|
|
503
|
+
if (notification.ownerSessionId !== sessionId ||
|
|
504
|
+
notification.responderLeaseId !== current) {
|
|
505
|
+
continue;
|
|
364
506
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
notification.
|
|
507
|
+
this.claimedNotifications.delete(notification.id);
|
|
508
|
+
// Releasing a runtime lease must not discard an unread durable result;
|
|
509
|
+
// detach it so the next lease for this session adopts it.
|
|
510
|
+
if (!notification.acknowledgedAt && !notification.archivedAt) {
|
|
511
|
+
delete notification.responderLeaseId;
|
|
370
512
|
changed = true;
|
|
371
513
|
}
|
|
372
514
|
}
|
|
@@ -399,10 +541,60 @@ export class JobManager {
|
|
|
399
541
|
JSON.stringify(stdoutArtifact) ||
|
|
400
542
|
JSON.stringify(existing.stderrArtifact) !==
|
|
401
543
|
JSON.stringify(stderrArtifact)));
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
544
|
+
const resultHash = createHash("sha256")
|
|
545
|
+
.update([
|
|
546
|
+
job.status,
|
|
547
|
+
endedAt,
|
|
548
|
+
String(job.exitCode ?? ""),
|
|
549
|
+
String(job.signal ?? ""),
|
|
550
|
+
JSON.stringify(stdoutArtifact),
|
|
551
|
+
JSON.stringify(stderrArtifact),
|
|
552
|
+
].join("\0"))
|
|
553
|
+
.digest("hex")
|
|
554
|
+
.slice(0, 24);
|
|
555
|
+
const consumed = this.consumedResponderResults.get(job.id);
|
|
556
|
+
if (!existing && consumed?.resultHash === resultHash) {
|
|
557
|
+
return { created: false, updated: false };
|
|
558
|
+
}
|
|
559
|
+
const correctsConsumed = Boolean(!existing && consumed && consumed.resultHash !== resultHash);
|
|
560
|
+
if (correctsConsumed)
|
|
561
|
+
this.consumedResponderResults.delete(job.id);
|
|
562
|
+
// A materially different authoritative result is a new revision: every
|
|
563
|
+
// delivery/read/analysis marker belonged to the superseded result and must
|
|
564
|
+
// not suppress review of the corrected one.
|
|
565
|
+
const supersedes = Boolean(existing &&
|
|
566
|
+
completionChanged &&
|
|
567
|
+
(existing.resultHash === undefined || existing.resultHash !== resultHash));
|
|
568
|
+
const resultRevision = supersedes
|
|
569
|
+
? (existing?.resultRevision ?? 1) + 1
|
|
570
|
+
: correctsConsumed
|
|
571
|
+
? (consumed?.resultRevision ?? 1) + 1
|
|
572
|
+
: (existing?.resultRevision ?? 1);
|
|
573
|
+
const supersededRevisions = supersedes && existing
|
|
574
|
+
? [
|
|
575
|
+
...(existing.supersededRevisions ?? []),
|
|
576
|
+
{
|
|
577
|
+
resultRevision: existing.resultRevision ?? 1,
|
|
578
|
+
resultHash: existing.resultHash ?? "",
|
|
579
|
+
status: existing.status,
|
|
580
|
+
endedAt: existing.endedAt,
|
|
581
|
+
...(existing.exitCode !== undefined
|
|
582
|
+
? { exitCode: existing.exitCode }
|
|
583
|
+
: {}),
|
|
584
|
+
...(existing.signal !== undefined ? { signal: existing.signal } : {}),
|
|
585
|
+
...(existing.deliveredAt ? { deliveredAt: existing.deliveredAt } : {}),
|
|
586
|
+
...(existing.readAt ? { readAt: existing.readAt } : {}),
|
|
587
|
+
...(existing.analyzedAt ? { analyzedAt: existing.analyzedAt } : {}),
|
|
588
|
+
...(existing.acknowledgedAt
|
|
589
|
+
? { acknowledgedAt: existing.acknowledgedAt }
|
|
590
|
+
: {}),
|
|
591
|
+
...(existing.settledAt ? { settledAt: existing.settledAt } : {}),
|
|
592
|
+
},
|
|
593
|
+
].slice(-MAX_SUPERSEDED_REVISIONS)
|
|
594
|
+
: existing?.supersededRevisions;
|
|
595
|
+
const carried = supersedes ? undefined : existing;
|
|
596
|
+
const activeLeaseId = this.responderLeases.get(job.ownerSessionId);
|
|
597
|
+
const responderLeaseId = activeLeaseId ?? job.responderLeaseId ?? existing?.responderLeaseId;
|
|
406
598
|
const notification = {
|
|
407
599
|
id: existing?.id ??
|
|
408
600
|
`completion:${createHash("sha256").update(`${job.ownerSessionId}\0${job.id}\0${job.startedAt}`).digest("hex").slice(0, 24)}`,
|
|
@@ -423,21 +615,34 @@ export class JobManager {
|
|
|
423
615
|
...(job.signal !== undefined ? { signal: job.signal } : {}),
|
|
424
616
|
...(job.monitor !== undefined ? { monitor: job.monitor } : {}),
|
|
425
617
|
...(responderLeaseId ? { responderLeaseId } : {}),
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
...(
|
|
429
|
-
|
|
618
|
+
resultRevision,
|
|
619
|
+
resultHash,
|
|
620
|
+
...(supersededRevisions?.length ? { supersededRevisions } : {}),
|
|
621
|
+
...(carried?.deliveryStartedAt
|
|
622
|
+
? { deliveryStartedAt: carried.deliveryStartedAt }
|
|
430
623
|
: {}),
|
|
431
|
-
...(
|
|
432
|
-
|
|
624
|
+
...(carried?.deliveredAt ? { deliveredAt: carried.deliveredAt } : {}),
|
|
625
|
+
...(carried?.readAt ? { readAt: carried.readAt } : {}),
|
|
626
|
+
...((carried?.analyzedAt ?? carried?.acknowledgedAt)
|
|
627
|
+
? { analyzedAt: carried?.analyzedAt ?? carried?.acknowledgedAt }
|
|
433
628
|
: {}),
|
|
434
|
-
...(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
629
|
+
...(carried?.acknowledgedAt
|
|
630
|
+
? { acknowledgedAt: carried.acknowledgedAt }
|
|
631
|
+
: {}),
|
|
632
|
+
...(carried?.settledAt ? { settledAt: carried.settledAt } : {}),
|
|
633
|
+
...(carried?.discardedAt
|
|
634
|
+
? {
|
|
635
|
+
discardedAt: carried.discardedAt,
|
|
636
|
+
...(carried.discardReason
|
|
637
|
+
? { discardReason: carried.discardReason }
|
|
638
|
+
: {}),
|
|
639
|
+
wakeOnCompletion: false,
|
|
640
|
+
}
|
|
641
|
+
: {}),
|
|
642
|
+
...(carried?.archivedAt ? { archivedAt: carried.archivedAt } : {}),
|
|
440
643
|
};
|
|
644
|
+
if (supersedes)
|
|
645
|
+
this.claimedNotifications.delete(notification.id);
|
|
441
646
|
const updated = Boolean(existing && JSON.stringify(existing) !== JSON.stringify(notification));
|
|
442
647
|
this.notifications.set(notification.id, notification);
|
|
443
648
|
return { notification, created: !existing, updated };
|
|
@@ -514,11 +719,22 @@ export class JobManager {
|
|
|
514
719
|
}
|
|
515
720
|
}
|
|
516
721
|
/** Reconcile a restored process that no longer has a ChildProcess close event. */
|
|
517
|
-
refreshJobLiveness(job) {
|
|
722
|
+
refreshJobLiveness(job, options = {}) {
|
|
518
723
|
if (!this.isLive(job) || this.processes.has(job.id)) {
|
|
519
724
|
this.livenessMisses.delete(job.id);
|
|
725
|
+
this.livenessCheckedAt.delete(job.id);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
// Subscribers can ask for state many times per frame; one probe per job per
|
|
729
|
+
// interval is enough because the lost grace window is far longer.
|
|
730
|
+
const checkedAt = this.livenessCheckedAt.get(job.id);
|
|
731
|
+
const probeAt = Date.now();
|
|
732
|
+
if (!options.force &&
|
|
733
|
+
checkedAt !== undefined &&
|
|
734
|
+
probeAt - checkedAt < LIVENESS_PROBE_INTERVAL_MS) {
|
|
520
735
|
return;
|
|
521
736
|
}
|
|
737
|
+
this.livenessCheckedAt.set(job.id, probeAt);
|
|
522
738
|
// Primary signal is the pid itself. If the process is alive we keep the job
|
|
523
739
|
// running and only ever declare "lost" on a PROVEN pid reuse (both the
|
|
524
740
|
// stored and the current identity are present AND differ). An identity read
|
|
@@ -532,6 +748,9 @@ export class JobManager {
|
|
|
532
748
|
return;
|
|
533
749
|
}
|
|
534
750
|
}
|
|
751
|
+
else {
|
|
752
|
+
forgetProcessIdentity(job.pid);
|
|
753
|
+
}
|
|
535
754
|
// The process looks gone (or is a proven pid reuse). Require the miss to
|
|
536
755
|
// persist across a grace window before finalizing — a single transient
|
|
537
756
|
// hiccup must not kill a job whose real close event is still in flight.
|
|
@@ -570,47 +789,122 @@ export class JobManager {
|
|
|
570
789
|
clearTimeout(existing);
|
|
571
790
|
const timer = setTimeout(() => {
|
|
572
791
|
this.settlementTimers.delete(job.id);
|
|
792
|
+
const revision = this.notificationForJob(job.id)?.resultRevision ?? 1;
|
|
573
793
|
void import("../store/responder-settlement.js")
|
|
574
|
-
.then(({ settleResponderJob }) => settleResponderJob({ ...job }))
|
|
794
|
+
.then(({ settleResponderJob }) => settleResponderJob({ ...job }, { resultRevision: revision }))
|
|
575
795
|
.then((result) => {
|
|
576
796
|
if (result === "applied" || result === "noop") {
|
|
577
|
-
this.
|
|
578
|
-
const notification = this.notificationForJob(job.id);
|
|
579
|
-
if (notification && !notification.settledAt) {
|
|
580
|
-
notification.settledAt = new Date().toISOString();
|
|
581
|
-
if (!this.persistSync())
|
|
582
|
-
this.scheduleRegistryRetry();
|
|
583
|
-
this.emit({
|
|
584
|
-
type: "notification",
|
|
585
|
-
jobId: job.id,
|
|
586
|
-
notificationId: notification.id,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
797
|
+
this.completeSettlement(job.id, revision);
|
|
589
798
|
return;
|
|
590
799
|
}
|
|
591
|
-
|
|
592
|
-
this.settlementAttempts.set(job.id, attempt);
|
|
593
|
-
if (attempt < 8) {
|
|
594
|
-
this.scheduleTaskSettlement(job, Math.min(5_000, 50 * 2 ** attempt));
|
|
595
|
-
}
|
|
596
|
-
else {
|
|
597
|
-
this.settlementAttempts.delete(job.id);
|
|
598
|
-
}
|
|
800
|
+
this.retrySettlement(job, revision, result);
|
|
599
801
|
})
|
|
600
|
-
.catch(() => {
|
|
601
|
-
|
|
602
|
-
this.settlementAttempts.set(job.id, attempt);
|
|
603
|
-
if (attempt < 8) {
|
|
604
|
-
this.scheduleTaskSettlement(job, Math.min(5_000, 50 * 2 ** attempt));
|
|
605
|
-
}
|
|
606
|
-
else {
|
|
607
|
-
this.settlementAttempts.delete(job.id);
|
|
608
|
-
}
|
|
802
|
+
.catch((error) => {
|
|
803
|
+
this.retrySettlement(job, revision, error instanceof Error ? error.message : String(error));
|
|
609
804
|
});
|
|
610
805
|
}, delayMs);
|
|
611
806
|
timer.unref?.();
|
|
612
807
|
this.settlementTimers.set(job.id, timer);
|
|
613
808
|
}
|
|
809
|
+
completeSettlement(jobId, resultRevision) {
|
|
810
|
+
this.pendingSettlements.delete(jobId);
|
|
811
|
+
const notification = this.notificationForJob(jobId);
|
|
812
|
+
if (!notification ||
|
|
813
|
+
(notification.resultRevision ?? 1) !== resultRevision ||
|
|
814
|
+
notification.settledAt) {
|
|
815
|
+
if (!this.persistSync())
|
|
816
|
+
this.scheduleRegistryRetry();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
notification.settledAt = new Date().toISOString();
|
|
820
|
+
if (!this.persistSync())
|
|
821
|
+
this.scheduleRegistryRetry();
|
|
822
|
+
this.emit({
|
|
823
|
+
type: "notification",
|
|
824
|
+
jobId,
|
|
825
|
+
notificationId: notification.id,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Retry until the projection lands. The marker is durable, so a crash or a
|
|
830
|
+
* long-lived plan-write conflict cannot silently strand a green child, and an
|
|
831
|
+
* unrecoverable case is dead-lettered with a visible reason.
|
|
832
|
+
*/
|
|
833
|
+
retrySettlement(job, resultRevision, reason) {
|
|
834
|
+
const now = new Date().toISOString();
|
|
835
|
+
const previous = this.pendingSettlements.get(job.id);
|
|
836
|
+
const carryOver = previous && previous.resultRevision === resultRevision ? previous : undefined;
|
|
837
|
+
const attempts = (carryOver?.attempts ?? 0) + 1;
|
|
838
|
+
const firstAttemptAt = carryOver?.firstAttemptAt ?? now;
|
|
839
|
+
const elapsed = Date.now() - Date.parse(firstAttemptAt);
|
|
840
|
+
const deadLettered = Number.isFinite(elapsed) && elapsed > SETTLEMENT_DEAD_LETTER_MS;
|
|
841
|
+
this.pendingSettlements.set(job.id, {
|
|
842
|
+
jobId: job.id,
|
|
843
|
+
resultRevision,
|
|
844
|
+
attempts,
|
|
845
|
+
firstAttemptAt,
|
|
846
|
+
lastAttemptAt: now,
|
|
847
|
+
lastReason: reason,
|
|
848
|
+
...(deadLettered ? { deadLetteredAt: now } : {}),
|
|
849
|
+
});
|
|
850
|
+
if (!this.persistSync())
|
|
851
|
+
this.scheduleRegistryRetry();
|
|
852
|
+
this.emit({ type: "job", jobId: job.id });
|
|
853
|
+
if (deadLettered)
|
|
854
|
+
return;
|
|
855
|
+
this.scheduleTaskSettlement(job, Math.min(SETTLEMENT_MAX_BACKOFF_MS, 50 * 2 ** Math.min(attempts, 10)));
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* One coalesced, unref'd watcher that reconciles restored live jobs without a
|
|
859
|
+
* UI read. Without it a headless session can leave a finished responder job
|
|
860
|
+
* "running" forever, because liveness was only refreshed by list/get calls.
|
|
861
|
+
*/
|
|
862
|
+
scheduleLivenessWatch() {
|
|
863
|
+
if (this.livenessWatchTimer)
|
|
864
|
+
return;
|
|
865
|
+
const restored = [...this.jobs.values()].filter((job) => this.isDurable(job) && this.isLive(job) && !this.processes.has(job.id));
|
|
866
|
+
if (restored.length === 0)
|
|
867
|
+
return;
|
|
868
|
+
const timer = setInterval(() => {
|
|
869
|
+
const pending = [...this.jobs.values()].filter((job) => this.isDurable(job) && this.isLive(job) && !this.processes.has(job.id));
|
|
870
|
+
for (const job of pending)
|
|
871
|
+
this.refreshJobLiveness(job);
|
|
872
|
+
const remaining = [...this.jobs.values()].some((job) => this.isDurable(job) && this.isLive(job) && !this.processes.has(job.id));
|
|
873
|
+
if (remaining)
|
|
874
|
+
return;
|
|
875
|
+
clearInterval(timer);
|
|
876
|
+
this.livenessWatchTimer = undefined;
|
|
877
|
+
}, LIVENESS_WATCH_INTERVAL_MS);
|
|
878
|
+
timer.unref?.();
|
|
879
|
+
this.livenessWatchTimer = timer;
|
|
880
|
+
}
|
|
881
|
+
/** Ownership guard: a receipt may only be mutated by its owning session. */
|
|
882
|
+
ownsNotification(notification, sessionId) {
|
|
883
|
+
if (!notification)
|
|
884
|
+
return false;
|
|
885
|
+
return sessionId === undefined || notification.ownerSessionId === sessionId;
|
|
886
|
+
}
|
|
887
|
+
/** Receipt for a launch that was deduplicated by its delegation key. */
|
|
888
|
+
startJobReceipt(job, reason) {
|
|
889
|
+
return {
|
|
890
|
+
ok: true,
|
|
891
|
+
output: `Reusing background job id=${job.id} (${reason}) pid=${job.pid ?? "?"} status=${job.status}\n` +
|
|
892
|
+
`Command: ${job.commandDisplay}\nArtifact: ${job.stdoutArtifact}\n` +
|
|
893
|
+
`The duplicate launch was not started. ${launchFollowUp(job.id, job.responder === true)}`,
|
|
894
|
+
outputPath: job.stdoutArtifact,
|
|
895
|
+
backgroundJob: {
|
|
896
|
+
id: job.id,
|
|
897
|
+
status: job.status,
|
|
898
|
+
artifactPath: job.stdoutArtifact,
|
|
899
|
+
...(job.responder ? { responder: true } : {}),
|
|
900
|
+
nextOffset: 0,
|
|
901
|
+
},
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
/** Terminal results whose plan child could not be settled yet. */
|
|
905
|
+
getPendingSettlements() {
|
|
906
|
+
return [...this.pendingSettlements.values()];
|
|
907
|
+
}
|
|
614
908
|
/**
|
|
615
909
|
* Register an in-flight tool for stall tracking only.
|
|
616
910
|
* Never appears in shell.jobs and never touches the durable registry.
|
|
@@ -644,7 +938,7 @@ export class JobManager {
|
|
|
644
938
|
if (!this.isLive(job))
|
|
645
939
|
this.clearAuthorizationTimer(id);
|
|
646
940
|
if (!this.isDurable(job) && !this.isLive(job)) {
|
|
647
|
-
this.
|
|
941
|
+
this.forgetJob(id);
|
|
648
942
|
this.emit({ type: "job", jobId: id });
|
|
649
943
|
return;
|
|
650
944
|
}
|
|
@@ -661,6 +955,14 @@ export class JobManager {
|
|
|
661
955
|
return { ok: false, output: `Engagement authorization for ${options.authorization.target} has expired.`, exitCode: 1 };
|
|
662
956
|
}
|
|
663
957
|
}
|
|
958
|
+
if (options?.delegationId) {
|
|
959
|
+
// A retried tool call must not spawn a second scanner: the delegation key
|
|
960
|
+
// identifies the work, so the existing job is returned unchanged.
|
|
961
|
+
const existing = [...this.jobs.values()].find((candidate) => candidate.delegationId === options.delegationId &&
|
|
962
|
+
candidate.ownerSessionId === (options.ownerSessionId ?? "unknown"));
|
|
963
|
+
if (existing)
|
|
964
|
+
return this.startJobReceipt(existing, "existing delegation");
|
|
965
|
+
}
|
|
664
966
|
const id = randomUUID().slice(0, 8);
|
|
665
967
|
const cwd = options?.cwd ?? safeCwd();
|
|
666
968
|
try {
|
|
@@ -691,7 +993,7 @@ export class JobManager {
|
|
|
691
993
|
exitCode: 127,
|
|
692
994
|
};
|
|
693
995
|
}
|
|
694
|
-
await mkdir(this.jobsDir, { recursive: true });
|
|
996
|
+
await mkdir(this.jobsDir, { recursive: true, mode: 0o700 });
|
|
695
997
|
const prefix = `${new Date().toISOString().replace(/[:.]/g, "-")}-${id}`;
|
|
696
998
|
const stdoutArtifact = join(this.jobsDir, `${prefix}.stdout.log`);
|
|
697
999
|
const stderrArtifact = join(this.jobsDir, `${prefix}.stderr.log`);
|
|
@@ -721,6 +1023,7 @@ export class JobManager {
|
|
|
721
1023
|
...(options?.name ? { name: options.name } : {}),
|
|
722
1024
|
...(options?.taskId ? { taskId: options.taskId } : {}),
|
|
723
1025
|
...(options?.parentTaskId ? { parentTaskId: options.parentTaskId } : {}),
|
|
1026
|
+
...(options?.delegationId ? { delegationId: options.delegationId } : {}),
|
|
724
1027
|
...(options?.wakeOnCompletion !== undefined ? { wakeOnCompletion: options.wakeOnCompletion } : {}),
|
|
725
1028
|
...(options?.responder !== undefined ? { responder: options.responder } : {}),
|
|
726
1029
|
...(monitor !== undefined ? { monitor } : {}),
|
|
@@ -735,7 +1038,7 @@ export class JobManager {
|
|
|
735
1038
|
await this.persist();
|
|
736
1039
|
}
|
|
737
1040
|
catch {
|
|
738
|
-
this.
|
|
1041
|
+
this.forgetJob(id);
|
|
739
1042
|
return {
|
|
740
1043
|
ok: false,
|
|
741
1044
|
output: "Background command launch error [PERSIST_FAILED]: could not persist the job registry; the command was not started.",
|
|
@@ -801,10 +1104,14 @@ export class JobManager {
|
|
|
801
1104
|
launchError.syscall ? `syscall=${JSON.stringify(launchError.syscall)}` : undefined,
|
|
802
1105
|
launchError.path ? `path=${JSON.stringify(launchError.path)}` : undefined,
|
|
803
1106
|
].filter((value) => Boolean(value));
|
|
1107
|
+
const ownership = job.responder
|
|
1108
|
+
? "Responder owns this terminal launch failure; do not poll or retry it. Its result will be delivered automatically."
|
|
1109
|
+
: "Inspect the failed job receipt before deciding whether changed evidence supports a retry.";
|
|
804
1110
|
const detail = `${launchRetried ? "Automatic retry after a transient launch ENOENT also failed.\n" : ""}` +
|
|
805
1111
|
`Background command launch error [${code}]: ${launchError.message}\n` +
|
|
806
1112
|
`${fields.join("; ")}\n` +
|
|
807
|
-
"The command did not start. Do not rewrite its syntax to work around this infrastructure error; verify the target and cwd
|
|
1113
|
+
"The command did not start. Do not rewrite its syntax to work around this infrastructure error; verify the target and cwd.\n" +
|
|
1114
|
+
ownership;
|
|
808
1115
|
stderr.append(`${detail}\n`);
|
|
809
1116
|
await this.finalizeJob(job, "failed", { exitCode: 127 });
|
|
810
1117
|
return {
|
|
@@ -817,6 +1124,7 @@ export class JobManager {
|
|
|
817
1124
|
status: "failed",
|
|
818
1125
|
exitCode: 127,
|
|
819
1126
|
artifactPath: stdoutArtifact,
|
|
1127
|
+
...(job.responder ? { responder: true } : {}),
|
|
820
1128
|
nextOffset: 0,
|
|
821
1129
|
},
|
|
822
1130
|
};
|
|
@@ -831,17 +1139,23 @@ export class JobManager {
|
|
|
831
1139
|
job.processGroupId = detached ? child.pid : undefined;
|
|
832
1140
|
job.status = "running";
|
|
833
1141
|
job.heartbeatAt = new Date().toISOString();
|
|
834
|
-
job.processIdentity = processIdentity(child.pid);
|
|
1142
|
+
job.processIdentity = processIdentity(child.pid, { refresh: true });
|
|
835
1143
|
this.processes.set(id, child);
|
|
836
1144
|
this.writers.set(id, { stdout, stderr });
|
|
837
1145
|
this.scheduleAuthorizationExpiry(job);
|
|
838
1146
|
let lastProgressFlush = 0;
|
|
1147
|
+
let lastDurableFlush = Date.now();
|
|
839
1148
|
let progressDirty = false;
|
|
840
1149
|
let progressTimer;
|
|
1150
|
+
// UI freshness is cheap; rewriting the whole registry is not. Terminal
|
|
1151
|
+
// transitions always persist authoritatively below.
|
|
841
1152
|
const flushProgress = () => {
|
|
842
1153
|
progressDirty = false;
|
|
843
1154
|
lastProgressFlush = Date.now();
|
|
844
|
-
|
|
1155
|
+
if (lastProgressFlush - lastDurableFlush >= DURABLE_PROGRESS_FLUSH_MS) {
|
|
1156
|
+
lastDurableFlush = lastProgressFlush;
|
|
1157
|
+
this.persistSync();
|
|
1158
|
+
}
|
|
845
1159
|
this.emit({ type: "job", jobId: id });
|
|
846
1160
|
};
|
|
847
1161
|
// Coalesce high-frequency stdout/stderr chunks: a chatty job (scanner,
|
|
@@ -937,10 +1251,13 @@ export class JobManager {
|
|
|
937
1251
|
const earlyStatus = this.getJob(id)?.status;
|
|
938
1252
|
if (earlyStatus === "failed") {
|
|
939
1253
|
await this.persist();
|
|
1254
|
+
const failureFollowUp = job.responder
|
|
1255
|
+
? "Responder owns this terminal failure; do not poll or retry it. Its result will be delivered automatically."
|
|
1256
|
+
: `Use shell.tail {"id":"${id}"} for captured stderr; do not retry unchanged.`;
|
|
940
1257
|
return {
|
|
941
1258
|
ok: false,
|
|
942
1259
|
output: `Background job failed immediately: id=${id} exit=${job.exitCode ?? "?"}\n` +
|
|
943
|
-
`Command: ${job.commandDisplay}\
|
|
1260
|
+
`Command: ${job.commandDisplay}\n${failureFollowUp}`,
|
|
944
1261
|
exitCode: job.exitCode ?? 1,
|
|
945
1262
|
outputPath: stdoutArtifact,
|
|
946
1263
|
backgroundJob: {
|
|
@@ -948,6 +1265,7 @@ export class JobManager {
|
|
|
948
1265
|
status: "failed",
|
|
949
1266
|
exitCode: job.exitCode,
|
|
950
1267
|
artifactPath: stdoutArtifact,
|
|
1268
|
+
...(job.responder ? { responder: true } : {}),
|
|
951
1269
|
nextOffset: 0,
|
|
952
1270
|
},
|
|
953
1271
|
};
|
|
@@ -957,14 +1275,14 @@ export class JobManager {
|
|
|
957
1275
|
return {
|
|
958
1276
|
ok: true,
|
|
959
1277
|
output: `OS process launch confirmed: id=${id} (canonical job ID) pid=${child.pid ?? "?"}\n` +
|
|
960
|
-
`
|
|
961
|
-
|
|
962
|
-
`Command: ${job.commandDisplay}\nArtifact: ${stdoutArtifact}`,
|
|
1278
|
+
`Command: ${job.commandDisplay}\nArtifact: ${stdoutArtifact}\n` +
|
|
1279
|
+
launchFollowUp(id, job.responder === true),
|
|
963
1280
|
outputPath: stdoutArtifact,
|
|
964
1281
|
backgroundJob: {
|
|
965
1282
|
id,
|
|
966
1283
|
status: job.status,
|
|
967
1284
|
artifactPath: stdoutArtifact,
|
|
1285
|
+
...(job.responder ? { responder: true } : {}),
|
|
968
1286
|
...(options?.profile ? { profile: options.profile } : {}),
|
|
969
1287
|
...(options?.estimatedSeconds !== undefined ? { estimatedSeconds: options.estimatedSeconds } : {}),
|
|
970
1288
|
nextOffset: 0,
|
|
@@ -983,10 +1301,12 @@ export class JobManager {
|
|
|
983
1301
|
this.persistSync();
|
|
984
1302
|
this.emit({ type: "job", jobId: id });
|
|
985
1303
|
}
|
|
1304
|
+
const followUp = launchFollowUp(id, job.responder === true);
|
|
986
1305
|
return {
|
|
987
1306
|
ok: alive,
|
|
988
1307
|
output: `Background command launched as pid=${job.pid ?? "?"}, but job setup/persistence failed: ${detail}\n` +
|
|
989
|
-
`${alive ? `The process is still running as job ${id}; do not launch a duplicate.
|
|
1308
|
+
`${alive ? `The process is still running as job ${id}; do not launch a duplicate. ` : "The process has already stopped. "}` +
|
|
1309
|
+
followUp,
|
|
990
1310
|
exitCode: alive ? undefined : job.exitCode,
|
|
991
1311
|
outputPath: stdoutArtifact,
|
|
992
1312
|
backgroundJob: {
|
|
@@ -994,6 +1314,7 @@ export class JobManager {
|
|
|
994
1314
|
status: job.status,
|
|
995
1315
|
exitCode: job.exitCode,
|
|
996
1316
|
artifactPath: stdoutArtifact,
|
|
1317
|
+
...(job.responder ? { responder: true } : {}),
|
|
997
1318
|
nextOffset: 0,
|
|
998
1319
|
},
|
|
999
1320
|
};
|
|
@@ -1002,8 +1323,18 @@ export class JobManager {
|
|
|
1002
1323
|
return {
|
|
1003
1324
|
ok: false,
|
|
1004
1325
|
output: `Background command launch error [UNKNOWN]: ${detail}\n` +
|
|
1005
|
-
`cwd=${JSON.stringify(cwd)}\nThe command did not start
|
|
1326
|
+
`cwd=${JSON.stringify(cwd)}\nThe command did not start.\n` +
|
|
1327
|
+
launchFollowUp(id, job.responder === true),
|
|
1006
1328
|
exitCode: 127,
|
|
1329
|
+
outputPath: stdoutArtifact,
|
|
1330
|
+
backgroundJob: {
|
|
1331
|
+
id,
|
|
1332
|
+
status: "failed",
|
|
1333
|
+
exitCode: 127,
|
|
1334
|
+
artifactPath: stdoutArtifact,
|
|
1335
|
+
...(job.responder ? { responder: true } : {}),
|
|
1336
|
+
nextOffset: 0,
|
|
1337
|
+
},
|
|
1007
1338
|
};
|
|
1008
1339
|
}
|
|
1009
1340
|
}
|
|
@@ -1039,7 +1370,27 @@ export class JobManager {
|
|
|
1039
1370
|
? "alive"
|
|
1040
1371
|
: "unresponsive"
|
|
1041
1372
|
: "terminal";
|
|
1042
|
-
|
|
1373
|
+
const notification = this.notificationForJob(job.id);
|
|
1374
|
+
const receipt = notification
|
|
1375
|
+
? notification.acknowledgedAt
|
|
1376
|
+
? "consumed"
|
|
1377
|
+
: notification.analyzedAt
|
|
1378
|
+
? "analyzed"
|
|
1379
|
+
: notification.readAt
|
|
1380
|
+
? "read"
|
|
1381
|
+
: notification.deliveredAt
|
|
1382
|
+
? "delivered-unread"
|
|
1383
|
+
: notification.deliveryStartedAt
|
|
1384
|
+
? "delivering"
|
|
1385
|
+
: "ready"
|
|
1386
|
+
: this.consumedResponderResults.has(job.id)
|
|
1387
|
+
? "consumed"
|
|
1388
|
+
: job.responder
|
|
1389
|
+
? this.isLive(job)
|
|
1390
|
+
? "running"
|
|
1391
|
+
: "pending"
|
|
1392
|
+
: undefined;
|
|
1393
|
+
return `[${job.id}] ${job.status} health=${health} exit=${job.exitCode ?? "?"}${receipt ? ` responder=${receipt}` : ""} ${formatJobElapsed(job)} ${job.commandDisplay.slice(0, 80)}`;
|
|
1043
1394
|
};
|
|
1044
1395
|
const header = sessionId
|
|
1045
1396
|
? `Session background jobs (${durable.length} total, session ${sessionId}):`
|
|
@@ -1082,6 +1433,7 @@ export class JobManager {
|
|
|
1082
1433
|
getPendingNotifications(sessionId) {
|
|
1083
1434
|
return [...this.notifications.values()]
|
|
1084
1435
|
.filter((notification) => !notification.acknowledgedAt &&
|
|
1436
|
+
!notification.discardedAt &&
|
|
1085
1437
|
(!sessionId || notification.ownerSessionId === sessionId))
|
|
1086
1438
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1087
1439
|
}
|
|
@@ -1091,22 +1443,24 @@ export class JobManager {
|
|
|
1091
1443
|
claimNextResponderNotification(sessionId, leaseId) {
|
|
1092
1444
|
if (this.responderLeases.get(sessionId) !== leaseId)
|
|
1093
1445
|
return undefined;
|
|
1094
|
-
|
|
1095
|
-
let archived = false;
|
|
1446
|
+
let adopted = false;
|
|
1096
1447
|
for (const notification of this.notifications.values()) {
|
|
1448
|
+
// Receipts belong to the session, not to a runtime lease: a new lease
|
|
1449
|
+
// adopts everything unread that an earlier lease left behind.
|
|
1097
1450
|
if (notification.ownerSessionId === sessionId &&
|
|
1098
1451
|
!notification.acknowledgedAt &&
|
|
1099
|
-
notification.
|
|
1100
|
-
!notification.
|
|
1101
|
-
notification.
|
|
1102
|
-
|
|
1452
|
+
!notification.archivedAt &&
|
|
1453
|
+
!notification.discardedAt &&
|
|
1454
|
+
notification.responderLeaseId !== leaseId) {
|
|
1455
|
+
notification.responderLeaseId = leaseId;
|
|
1456
|
+
adopted = true;
|
|
1103
1457
|
}
|
|
1104
1458
|
}
|
|
1105
1459
|
const notification = [...this.notifications.values()]
|
|
1106
1460
|
.filter((candidate) => candidate.ownerSessionId === sessionId &&
|
|
1107
1461
|
candidate.responderLeaseId === leaseId &&
|
|
1108
1462
|
!candidate.archivedAt &&
|
|
1109
|
-
!candidate.
|
|
1463
|
+
!candidate.discardedAt &&
|
|
1110
1464
|
!candidate.readAt &&
|
|
1111
1465
|
!candidate.analyzedAt &&
|
|
1112
1466
|
!candidate.acknowledgedAt &&
|
|
@@ -1114,26 +1468,53 @@ export class JobManager {
|
|
|
1114
1468
|
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))[0];
|
|
1115
1469
|
if (notification)
|
|
1116
1470
|
this.claimedNotifications.add(notification.id);
|
|
1117
|
-
if (
|
|
1471
|
+
if (adopted && !this.persistSync())
|
|
1118
1472
|
this.scheduleRegistryRetry();
|
|
1119
1473
|
return notification;
|
|
1120
1474
|
}
|
|
1121
1475
|
releaseResponderNotificationClaim(notificationId) {
|
|
1122
1476
|
this.claimedNotifications.delete(notificationId);
|
|
1123
1477
|
}
|
|
1124
|
-
|
|
1478
|
+
/**
|
|
1479
|
+
* Record that a delivery attempt started. This is deliberately weaker than
|
|
1480
|
+
* `markDelivered`: an aborted or failed analysis turn must remain deliverable.
|
|
1481
|
+
*/
|
|
1482
|
+
markDeliveryStarted(notificationId, sessionId) {
|
|
1125
1483
|
const notification = this.notifications.get(notificationId);
|
|
1126
|
-
if (!notification)
|
|
1484
|
+
if (!this.ownsNotification(notification, sessionId))
|
|
1485
|
+
return false;
|
|
1486
|
+
if (notification.deliveryStartedAt)
|
|
1487
|
+
return true;
|
|
1488
|
+
notification.deliveryStartedAt = new Date().toISOString();
|
|
1489
|
+
if (!this.persistSync()) {
|
|
1490
|
+
notification.deliveryStartedAt = undefined;
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
this.emit({
|
|
1494
|
+
type: "notification",
|
|
1495
|
+
jobId: notification.jobId,
|
|
1496
|
+
notificationId,
|
|
1497
|
+
});
|
|
1498
|
+
return true;
|
|
1499
|
+
}
|
|
1500
|
+
markDelivered(notificationId, sessionId) {
|
|
1501
|
+
const notification = this.notifications.get(notificationId);
|
|
1502
|
+
if (!this.ownsNotification(notification, sessionId))
|
|
1127
1503
|
return false;
|
|
1128
1504
|
if (!notification.deliveredAt) {
|
|
1129
1505
|
notification.deliveredAt = new Date().toISOString();
|
|
1506
|
+
notification.deliveryStartedAt ??= notification.deliveredAt;
|
|
1130
1507
|
if (!this.persistSync()) {
|
|
1131
1508
|
notification.deliveredAt = undefined;
|
|
1132
1509
|
return false;
|
|
1133
1510
|
}
|
|
1134
1511
|
this.emit({ type: "notification", jobId: notification.jobId, notificationId });
|
|
1135
1512
|
}
|
|
1136
|
-
|
|
1513
|
+
if (notification.readAt ||
|
|
1514
|
+
notification.analyzedAt ||
|
|
1515
|
+
notification.acknowledgedAt) {
|
|
1516
|
+
this.claimedNotifications.delete(notificationId);
|
|
1517
|
+
}
|
|
1137
1518
|
return true;
|
|
1138
1519
|
}
|
|
1139
1520
|
markRead(notificationId, sessionId) {
|
|
@@ -1141,7 +1522,8 @@ export class JobManager {
|
|
|
1141
1522
|
if (!notification ||
|
|
1142
1523
|
notification.ownerSessionId !== sessionId ||
|
|
1143
1524
|
!notification.responder ||
|
|
1144
|
-
notification.archivedAt
|
|
1525
|
+
notification.archivedAt ||
|
|
1526
|
+
notification.discardedAt) {
|
|
1145
1527
|
return false;
|
|
1146
1528
|
}
|
|
1147
1529
|
const previousDeliveredAt = notification.deliveredAt;
|
|
@@ -1162,10 +1544,14 @@ export class JobManager {
|
|
|
1162
1544
|
});
|
|
1163
1545
|
return true;
|
|
1164
1546
|
}
|
|
1165
|
-
markAnalyzed(notificationId) {
|
|
1547
|
+
markAnalyzed(notificationId, sessionId) {
|
|
1166
1548
|
const notification = this.notifications.get(notificationId);
|
|
1167
|
-
if (!notification
|
|
1549
|
+
if (!notification || !this.ownsNotification(notification, sessionId)) {
|
|
1168
1550
|
return false;
|
|
1551
|
+
}
|
|
1552
|
+
if (!notification.deliveredAt && !notification.deliveryStartedAt) {
|
|
1553
|
+
return false;
|
|
1554
|
+
}
|
|
1169
1555
|
this.claimedNotifications.delete(notificationId);
|
|
1170
1556
|
if (!notification.analyzedAt) {
|
|
1171
1557
|
notification.analyzedAt = new Date().toISOString();
|
|
@@ -1179,13 +1565,22 @@ export class JobManager {
|
|
|
1179
1565
|
}
|
|
1180
1566
|
return true;
|
|
1181
1567
|
}
|
|
1182
|
-
acknowledge(notificationId) {
|
|
1568
|
+
acknowledge(notificationId, sessionId) {
|
|
1183
1569
|
const notification = this.notifications.get(notificationId);
|
|
1184
|
-
if (!notification
|
|
1570
|
+
if (!notification || !this.ownsNotification(notification, sessionId)) {
|
|
1571
|
+
return false;
|
|
1572
|
+
}
|
|
1573
|
+
if (!notification.analyzedAt)
|
|
1185
1574
|
return false;
|
|
1186
1575
|
this.claimedNotifications.delete(notificationId);
|
|
1187
1576
|
if (!notification.acknowledgedAt) {
|
|
1188
1577
|
notification.acknowledgedAt = new Date().toISOString();
|
|
1578
|
+
this.consumedResponderResults.set(notification.jobId, {
|
|
1579
|
+
jobId: notification.jobId,
|
|
1580
|
+
resultHash: notification.resultHash ?? "",
|
|
1581
|
+
resultRevision: notification.resultRevision ?? 1,
|
|
1582
|
+
acknowledgedAt: notification.acknowledgedAt,
|
|
1583
|
+
});
|
|
1189
1584
|
const persisted = this.persistSync();
|
|
1190
1585
|
if (!persisted)
|
|
1191
1586
|
this.scheduleRegistryRetry();
|
|
@@ -1377,7 +1772,7 @@ export class JobManager {
|
|
|
1377
1772
|
const identityMatches = () => {
|
|
1378
1773
|
if (process.platform === "win32")
|
|
1379
1774
|
return processAlive(pid);
|
|
1380
|
-
const current = processIdentity(pid);
|
|
1775
|
+
const current = processIdentity(pid, { refresh: true });
|
|
1381
1776
|
return Boolean(current && job.processIdentity && current === job.processIdentity);
|
|
1382
1777
|
};
|
|
1383
1778
|
if (targetAlive() && (!processAlive(pid) || !identityMatches())) {
|
|
@@ -1415,13 +1810,13 @@ export class JobManager {
|
|
|
1415
1810
|
else if (!(processGroupId && processGroupVerified && allowVerifiedGroup)) {
|
|
1416
1811
|
return "identity-mismatch";
|
|
1417
1812
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1813
|
+
const outcome = terminateProcessTree(pid, {
|
|
1814
|
+
signal,
|
|
1815
|
+
processGroupId,
|
|
1816
|
+
});
|
|
1817
|
+
if (outcome === "failed")
|
|
1423
1818
|
return targetAlive() ? "failed" : "gone";
|
|
1424
|
-
|
|
1819
|
+
return outcome;
|
|
1425
1820
|
};
|
|
1426
1821
|
const restoreRunning = () => {
|
|
1427
1822
|
job.status = "running";
|
|
@@ -1536,15 +1931,16 @@ export class JobManager {
|
|
|
1536
1931
|
acknowledgedAt: notification.acknowledgedAt,
|
|
1537
1932
|
});
|
|
1538
1933
|
notification.wakeOnCompletion = false;
|
|
1539
|
-
notification.
|
|
1540
|
-
notification.
|
|
1934
|
+
notification.discardedAt = acknowledgedAt;
|
|
1935
|
+
notification.discardReason = "session-cancelled";
|
|
1936
|
+
this.claimedNotifications.delete(notification.id);
|
|
1541
1937
|
}
|
|
1542
1938
|
const acknowledgementsPersisted = this.persistSync();
|
|
1543
1939
|
if (!acknowledgementsPersisted) {
|
|
1544
1940
|
for (const snapshot of notificationSnapshots) {
|
|
1545
1941
|
snapshot.notification.wakeOnCompletion = snapshot.wakeOnCompletion;
|
|
1546
|
-
snapshot.notification.
|
|
1547
|
-
snapshot.notification.
|
|
1942
|
+
snapshot.notification.discardedAt = undefined;
|
|
1943
|
+
snapshot.notification.discardReason = undefined;
|
|
1548
1944
|
}
|
|
1549
1945
|
}
|
|
1550
1946
|
else {
|
|
@@ -1611,21 +2007,26 @@ export class JobManager {
|
|
|
1611
2007
|
if (timer)
|
|
1612
2008
|
clearTimeout(timer);
|
|
1613
2009
|
this.settlementTimers.delete(notification.jobId);
|
|
1614
|
-
this.
|
|
2010
|
+
this.pendingSettlements.delete(notification.jobId);
|
|
1615
2011
|
}
|
|
1616
2012
|
for (const [id, notification] of this.notifications) {
|
|
1617
2013
|
const job = this.jobs.get(notification.jobId);
|
|
1618
2014
|
if (!job ||
|
|
2015
|
+
notification.discardedAt ||
|
|
1619
2016
|
(notification.acknowledgedAt && job.status !== "lost")) {
|
|
1620
2017
|
this.notifications.delete(id);
|
|
1621
2018
|
}
|
|
1622
2019
|
}
|
|
2020
|
+
for (const [jobId] of this.consumedResponderResults) {
|
|
2021
|
+
if (!this.jobs.has(jobId))
|
|
2022
|
+
this.consumedResponderResults.delete(jobId);
|
|
2023
|
+
}
|
|
1623
2024
|
const durableTerminal = [];
|
|
1624
2025
|
for (const [id, job] of this.jobs) {
|
|
1625
2026
|
if (!this.isDurable(job)) {
|
|
1626
2027
|
// Safety: never keep ephemeral rows that are not live.
|
|
1627
2028
|
if (!this.isLive(job))
|
|
1628
|
-
this.
|
|
2029
|
+
this.forgetJob(id);
|
|
1629
2030
|
continue;
|
|
1630
2031
|
}
|
|
1631
2032
|
if (this.isLive(job))
|
|
@@ -1638,7 +2039,7 @@ export class JobManager {
|
|
|
1638
2039
|
}
|
|
1639
2040
|
const ended = job.endedAt ? Date.parse(job.endedAt) : Date.parse(job.startedAt);
|
|
1640
2041
|
if (Number.isFinite(ended) && now - ended > TERMINAL_JOB_MAX_AGE_MS) {
|
|
1641
|
-
this.
|
|
2042
|
+
this.forgetJob(id);
|
|
1642
2043
|
continue;
|
|
1643
2044
|
}
|
|
1644
2045
|
durableTerminal.push(job);
|
|
@@ -1647,7 +2048,7 @@ export class JobManager {
|
|
|
1647
2048
|
return;
|
|
1648
2049
|
durableTerminal.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
1649
2050
|
for (const job of durableTerminal.slice(MAX_DURABLE_TERMINAL_JOBS)) {
|
|
1650
|
-
this.
|
|
2051
|
+
this.forgetJob(job.id);
|
|
1651
2052
|
}
|
|
1652
2053
|
}
|
|
1653
2054
|
loadAndReconcile() {
|
|
@@ -1667,6 +2068,23 @@ export class JobManager {
|
|
|
1667
2068
|
catch { /* a corrupt current registry must not replay stale v1 state */ }
|
|
1668
2069
|
if (!parsed)
|
|
1669
2070
|
return;
|
|
2071
|
+
if (parsed.schemaVersion === 2 && Array.isArray(parsed.settlements)) {
|
|
2072
|
+
for (const settlement of parsed.settlements) {
|
|
2073
|
+
if (!settlement || typeof settlement.jobId !== "string")
|
|
2074
|
+
continue;
|
|
2075
|
+
this.pendingSettlements.set(settlement.jobId, settlement);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
if (parsed.schemaVersion === 2 && Array.isArray(parsed.consumedResults)) {
|
|
2079
|
+
for (const consumed of parsed.consumedResults) {
|
|
2080
|
+
if (!consumed ||
|
|
2081
|
+
typeof consumed.jobId !== "string" ||
|
|
2082
|
+
typeof consumed.resultHash !== "string") {
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
this.consumedResponderResults.set(consumed.jobId, consumed);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
1670
2088
|
if (parsed.schemaVersion === 2 && Array.isArray(parsed.notifications)) {
|
|
1671
2089
|
const notifiedJobs = new Set();
|
|
1672
2090
|
for (const notification of parsed.notifications) {
|
|
@@ -1676,7 +2094,9 @@ export class JobManager {
|
|
|
1676
2094
|
continue;
|
|
1677
2095
|
notifiedJobs.add(notification.jobId);
|
|
1678
2096
|
if (!notification.acknowledgedAt && !notification.archivedAt) {
|
|
1679
|
-
|
|
2097
|
+
// A durable receipt must survive the restart it exists for. Drop the
|
|
2098
|
+
// dead runtime lease so the next activated lease can adopt it.
|
|
2099
|
+
delete notification.responderLeaseId;
|
|
1680
2100
|
}
|
|
1681
2101
|
this.notifications.set(notification.id, notification);
|
|
1682
2102
|
}
|
|
@@ -1713,6 +2133,7 @@ export class JobManager {
|
|
|
1713
2133
|
if (!job?.responder)
|
|
1714
2134
|
this.notifications.delete(id);
|
|
1715
2135
|
}
|
|
2136
|
+
this.scheduleLivenessWatch();
|
|
1716
2137
|
this.pruneTerminalJobs();
|
|
1717
2138
|
this.persistSync();
|
|
1718
2139
|
}
|
|
@@ -1724,6 +2145,12 @@ export class JobManager {
|
|
|
1724
2145
|
schemaVersion: 2,
|
|
1725
2146
|
jobs: [...this.jobs.values()].filter((job) => this.isDurable(job)),
|
|
1726
2147
|
notifications: [...this.notifications.values()],
|
|
2148
|
+
...(this.pendingSettlements.size
|
|
2149
|
+
? { settlements: [...this.pendingSettlements.values()] }
|
|
2150
|
+
: {}),
|
|
2151
|
+
...(this.consumedResponderResults.size
|
|
2152
|
+
? { consumedResults: [...this.consumedResponderResults.values()] }
|
|
2153
|
+
: {}),
|
|
1727
2154
|
};
|
|
1728
2155
|
}
|
|
1729
2156
|
scheduleRegistryRetry() {
|
|
@@ -1739,7 +2166,7 @@ export class JobManager {
|
|
|
1739
2166
|
}
|
|
1740
2167
|
persistSync() {
|
|
1741
2168
|
try {
|
|
1742
|
-
mkdirSync(this.jobsDir, { recursive: true });
|
|
2169
|
+
mkdirSync(this.jobsDir, { recursive: true, mode: 0o700 });
|
|
1743
2170
|
const temp = `${this.registryPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1744
2171
|
writeFileSync(temp, `${JSON.stringify(this.registry(), null, 2)}\n`, { mode: 0o600 });
|
|
1745
2172
|
renameSync(temp, this.registryPath);
|