iterate-plugin 3.5.7 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +111 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +14 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +115 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
package/src/file-lock.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/file-lock.ts — generic cross-process advisory lock for `.iterate/` state.
|
|
3
|
+
*
|
|
4
|
+
* Several plugin writers mutate small JSON/JSONL state files with a
|
|
5
|
+
* read-modify-write cycle (fix registry, decision log, defense events,
|
|
6
|
+
* experience bank). `writeJsonAtomic` only makes a SINGLE write atomic — two
|
|
7
|
+
* plugin processes racing on the same project can still lose an update between
|
|
8
|
+
* their read and their write. The decision log already guards itself with a
|
|
9
|
+
* purpose-built mutex (see `tools/decision-log.ts`); this module generalizes
|
|
10
|
+
* that exact pattern so every other writer can serialize the same way.
|
|
11
|
+
*
|
|
12
|
+
* Semantics (identical to the decision-log lock, deliberately):
|
|
13
|
+
* - exclusive create (`openSync(..., 'wx')`) with the holder pid stamped in;
|
|
14
|
+
* - a lock whose owner process is gone, or whose mtime is ancient, is
|
|
15
|
+
* stolen (unlink + retry);
|
|
16
|
+
* - bounded wait — on timeout the lock degrades to "proceed unlocked"
|
|
17
|
+
* (returns a callable no-op release) instead of wedging the tool call;
|
|
18
|
+
* - the returned release is always callable, idempotent, and never throws.
|
|
19
|
+
*
|
|
20
|
+
* Best-effort by design: an advisory lock that cannot be taken must never turn
|
|
21
|
+
* a working tool into a failed one; the worst case is the pre-existing
|
|
22
|
+
* (unlocked) behavior.
|
|
23
|
+
*
|
|
24
|
+
* In-process callers get synchronous mutual exclusion through the same API —
|
|
25
|
+
* tools run on one thread, so acquiring twice in one process would self-deadlock;
|
|
26
|
+
* `withProjectLock` is therefore NOT reentrant and must not be nested for the
|
|
27
|
+
* same (projectRoot, name).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
closeSync,
|
|
32
|
+
existsSync,
|
|
33
|
+
mkdirSync,
|
|
34
|
+
openSync,
|
|
35
|
+
readFileSync,
|
|
36
|
+
statSync,
|
|
37
|
+
unlinkSync,
|
|
38
|
+
writeSync,
|
|
39
|
+
} from 'node:fs'
|
|
40
|
+
import { join } from 'node:path'
|
|
41
|
+
|
|
42
|
+
const LOCK_DIR = '.iterate'
|
|
43
|
+
const DEFAULT_WAIT_MS = 5000
|
|
44
|
+
const DEFAULT_STALE_MS = 10000
|
|
45
|
+
const POLL_MS = 25
|
|
46
|
+
|
|
47
|
+
/** Tunables for tests (short waits) and callers wanting a different budget. */
|
|
48
|
+
export interface LockOptions {
|
|
49
|
+
/** Max time to wait for a live holder before proceeding unlocked. */
|
|
50
|
+
waitMs?: number
|
|
51
|
+
/** Age after which an existing lock file is considered abandoned. */
|
|
52
|
+
staleMs?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
56
|
+
function processAlive(pid: number): boolean {
|
|
57
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0)
|
|
60
|
+
return true
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Synchronous sleep (Atomics.wait is a reliable sleep in Node). */
|
|
67
|
+
function sleepSync(ms: number): void {
|
|
68
|
+
const sab = new Int32Array(new SharedArrayBuffer(4))
|
|
69
|
+
Atomics.wait(sab, 0, 0, ms)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate a lock name into a safe file-name fragment.
|
|
74
|
+
* Throws on anything that could escape `.iterate/` (path separators, dots).
|
|
75
|
+
*/
|
|
76
|
+
function lockFileName(name: string): string {
|
|
77
|
+
if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
|
78
|
+
throw new Error(`invalid lock name: ${JSON.stringify(name)}`)
|
|
79
|
+
}
|
|
80
|
+
return `.${name}.lock`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
|
|
85
|
+
*
|
|
86
|
+
* Returns a release function: always callable, idempotent, never throws.
|
|
87
|
+
* A no-op (but callable) release is returned when the lock directory cannot be
|
|
88
|
+
* created or the wait times out — callers then proceed unlocked, which is the
|
|
89
|
+
* historical best-effort behavior, rather than failing the operation.
|
|
90
|
+
*/
|
|
91
|
+
export function acquireProjectLock(
|
|
92
|
+
projectRoot: string,
|
|
93
|
+
name: string,
|
|
94
|
+
options: LockOptions = {},
|
|
95
|
+
): () => void {
|
|
96
|
+
const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
|
|
97
|
+
? (options.waitMs as number)
|
|
98
|
+
: DEFAULT_WAIT_MS
|
|
99
|
+
const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
|
|
100
|
+
? (options.staleMs as number)
|
|
101
|
+
: DEFAULT_STALE_MS
|
|
102
|
+
|
|
103
|
+
const dir = join(projectRoot, LOCK_DIR)
|
|
104
|
+
if (!existsSync(dir)) {
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(dir, { recursive: true })
|
|
107
|
+
} catch {
|
|
108
|
+
return () => {}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const lockPath = join(dir, lockFileName(name))
|
|
112
|
+
const start = Date.now()
|
|
113
|
+
for (;;) {
|
|
114
|
+
let fd: number | null = null
|
|
115
|
+
try {
|
|
116
|
+
fd = openSync(lockPath, 'wx')
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
|
|
119
|
+
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
120
|
+
let stale = false
|
|
121
|
+
try {
|
|
122
|
+
const st = statSync(lockPath)
|
|
123
|
+
if (Date.now() - st.mtimeMs > staleMs) stale = true
|
|
124
|
+
else {
|
|
125
|
+
const pid = Number(readFileSync(lockPath, 'utf-8'))
|
|
126
|
+
if (!processAlive(pid)) stale = true
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
stale = true // unreadable/vanishing lock → retry as stale
|
|
130
|
+
}
|
|
131
|
+
if (stale) {
|
|
132
|
+
try {
|
|
133
|
+
unlinkSync(lockPath)
|
|
134
|
+
} catch {
|
|
135
|
+
// another holder stole it first — retry immediately
|
|
136
|
+
}
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (Date.now() - start > waitMs) return () => {}
|
|
140
|
+
sleepSync(POLL_MS)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
// Owned the lock: stamp the holder pid, then release once on return.
|
|
144
|
+
try {
|
|
145
|
+
writeSync(fd, String(process.pid))
|
|
146
|
+
} catch {
|
|
147
|
+
// pid stamp is advisory
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
closeSync(fd)
|
|
151
|
+
} catch {
|
|
152
|
+
// best-effort
|
|
153
|
+
}
|
|
154
|
+
let released = false
|
|
155
|
+
return () => {
|
|
156
|
+
if (released) return
|
|
157
|
+
released = true
|
|
158
|
+
try {
|
|
159
|
+
unlinkSync(lockPath)
|
|
160
|
+
} catch {
|
|
161
|
+
// already gone
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run `fn` while holding the advisory lock named `name`.
|
|
169
|
+
* The release is guaranteed via `finally`; `fn`'s value or error propagates
|
|
170
|
+
* unchanged. Not reentrant for the same (projectRoot, name) — see header.
|
|
171
|
+
*/
|
|
172
|
+
export function withProjectLock<T>(
|
|
173
|
+
projectRoot: string,
|
|
174
|
+
name: string,
|
|
175
|
+
fn: () => T,
|
|
176
|
+
options: LockOptions = {},
|
|
177
|
+
): T {
|
|
178
|
+
const release = acquireProjectLock(projectRoot, name, options)
|
|
179
|
+
try {
|
|
180
|
+
return fn()
|
|
181
|
+
} finally {
|
|
182
|
+
release()
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -51,36 +51,60 @@ export const name = 'iterate-plugin'
|
|
|
51
51
|
export const inject = ['tools', 'systemPrompt'] as const
|
|
52
52
|
|
|
53
53
|
export function apply(ctx: Context): void {
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
registerFixTool(ctx)
|
|
62
|
-
registerDiffTool(ctx)
|
|
63
|
-
registerRollbackTool(ctx)
|
|
64
|
-
registerCheckpointTool(ctx)
|
|
65
|
-
registerStatusTool(ctx)
|
|
66
|
-
registerHistoryTool(ctx)
|
|
67
|
-
registerPruneTool(ctx)
|
|
68
|
-
registerTranscriptTool(ctx)
|
|
69
|
-
// v3.0: Quality Command Center tools
|
|
70
|
-
registerExperienceBankTool(ctx)
|
|
71
|
-
registerQualityGateTool(ctx)
|
|
72
|
-
registerDefenseEventsTool(ctx)
|
|
73
|
-
|
|
74
|
-
// 2. Wire the observatory approval gate onto dsh's tools/pre-execute waterfall,
|
|
75
|
-
// and the live reviewer-activity feed onto tools/result.
|
|
54
|
+
// 0. Wire the SAFETY hooks BEFORE any tool is registered.
|
|
55
|
+
// Registration is a list of 17 independent calls; if one of them threw
|
|
56
|
+
// (duplicate name, malformed definition, hostile ctx) while the gate was
|
|
57
|
+
// registered last, the tools registered up to that point would keep
|
|
58
|
+
// running with NO `tools/pre-execute` approval gate — a fail-open hole
|
|
59
|
+
// created by load order. Registering the gate first means a partial load
|
|
60
|
+
// can only ever err on the side of MORE gating, never less.
|
|
76
61
|
registerSessionHooks(ctx)
|
|
77
62
|
registerLiveCapture(ctx)
|
|
78
63
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
64
|
+
// 1. Register the 17 tools (14 original + 3 v3.0). Each registration is
|
|
65
|
+
// isolated: one failing tool must not take down the other 16, the gate
|
|
66
|
+
// above, or the prompt below (a partial plugin that still gates and
|
|
67
|
+
// teaches the workflow beats an aborted `apply`). Failures are logged,
|
|
68
|
+
// never swallowed silently.
|
|
69
|
+
const toolRegistrations: Array<[string, () => void]> = [
|
|
70
|
+
['iterate_config', () => registerConfigTool(ctx)],
|
|
71
|
+
['iterate_validate', () => registerValidateTool(ctx)],
|
|
72
|
+
['iterate_decision_log', () => registerDecisionLogTool(ctx)],
|
|
73
|
+
['iterate_context', () => registerContextTool(ctx)],
|
|
74
|
+
['iterate_review', () => registerReviewTool(ctx)],
|
|
75
|
+
['iterate_triage', () => registerTriageTool(ctx)],
|
|
76
|
+
['iterate_fix', () => registerFixTool(ctx)],
|
|
77
|
+
['iterate_diff', () => registerDiffTool(ctx)],
|
|
78
|
+
['iterate_rollback', () => registerRollbackTool(ctx)],
|
|
79
|
+
['iterate_checkpoint', () => registerCheckpointTool(ctx)],
|
|
80
|
+
['iterate_status', () => registerStatusTool(ctx)],
|
|
81
|
+
['iterate_history', () => registerHistoryTool(ctx)],
|
|
82
|
+
['iterate_prune', () => registerPruneTool(ctx)],
|
|
83
|
+
['iterate_transcript', () => registerTranscriptTool(ctx)],
|
|
84
|
+
// v3.0: Quality Command Center tools
|
|
85
|
+
['iterate_experience', () => registerExperienceBankTool(ctx)],
|
|
86
|
+
['iterate_quality_gate', () => registerQualityGateTool(ctx)],
|
|
87
|
+
['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
|
|
88
|
+
]
|
|
89
|
+
for (const [toolName, register] of toolRegistrations) {
|
|
90
|
+
try {
|
|
91
|
+
register()
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn(`[iterate] failed to register ${toolName}; continuing.`, err)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2. Inject the iterate skill prompt as a system prompt section.
|
|
98
|
+
// This teaches the model how to write iterate workflow scripts using the
|
|
99
|
+
// tools. Guarded for the same reason as above: a prompt-injection failure
|
|
100
|
+
// must not undo a load that already wired the gate and the tools.
|
|
101
|
+
try {
|
|
102
|
+
ctx.systemPrompt.section({
|
|
103
|
+
name: 'iterate-skill',
|
|
104
|
+
order: 100,
|
|
105
|
+
text: ITERATE_SKILL_PROMPT,
|
|
106
|
+
})
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.warn('[iterate] failed to inject the skill prompt section; continuing.', err)
|
|
109
|
+
}
|
|
86
110
|
}
|
package/src/jobs.ts
CHANGED
|
@@ -11,10 +11,19 @@
|
|
|
11
11
|
* - `ctx.jobs` only exists when the dsh host loaded a job registry + a
|
|
12
12
|
* controller serves the calling owner (`@deepseek-ai/dsh-tool-jobs` or an
|
|
13
13
|
* equivalent). When it is missing, `start()` throws or is absent — we
|
|
14
|
-
* detect both
|
|
15
|
-
* pure enhancement and never breaks
|
|
14
|
+
* detect both (including a throwing `start` GETTER) and fall through to
|
|
15
|
+
* plain execution, so the Job Panel is a pure enhancement and never breaks
|
|
16
|
+
* a tool call.
|
|
16
17
|
* - The registry is memory-only and panel rows are read-only (no progress
|
|
17
18
|
* updates), so these jobs are completion records, not control channels.
|
|
19
|
+
* - Cancellation is honest, not theatrical. `JobHooks.cancel` is REQUIRED by
|
|
20
|
+
* `@deepseek-ai/dsh-jobs` (the registry calls it on kill/teardown), but the
|
|
21
|
+
* wrapped tool `fn` exposes no abort channel — a panel kill cannot stop it.
|
|
22
|
+
* So `cancel` records the REQUEST (synchronous + idempotent, first reason
|
|
23
|
+
* wins), best-effort surfaces it on the record's progress line, and lets the
|
|
24
|
+
* job settle with `fn`'s TRUE outcome, with the request annotated in
|
|
25
|
+
* `detail`. Settling the record `killed` while `fn()` keeps running would
|
|
26
|
+
* show a stopped job that is still running — a lie in the Job Panel.
|
|
18
27
|
*/
|
|
19
28
|
|
|
20
29
|
import type { JobOutcome, JobRegistry } from '@deepseek-ai/dsh-jobs'
|
|
@@ -30,12 +39,18 @@ declare module '@deepseek-ai/dsh-jobs' {
|
|
|
30
39
|
/** Custom job kinds this plugin registers. */
|
|
31
40
|
export type IterateJobKind = 'iterate-review' | 'iterate-fix'
|
|
32
41
|
|
|
42
|
+
/** The `JobHandle` surface we use (the registry passes the real one). */
|
|
43
|
+
interface JobHandleLike {
|
|
44
|
+
updateProgress?(line: string): void
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
/** Shape of the `ctx.jobs` surface we rely on (duck-typed for safety). */
|
|
34
48
|
interface JobsLike {
|
|
35
49
|
start(spec: {
|
|
36
50
|
kind: IterateJobKind
|
|
37
51
|
label: string
|
|
38
|
-
|
|
52
|
+
// `cancel` mirrors JobHooks: required, synchronous, idempotent.
|
|
53
|
+
run(job: JobHandleLike): { done: Promise<JobOutcome>; cancel: (reason?: string) => void }
|
|
39
54
|
}): string
|
|
40
55
|
}
|
|
41
56
|
|
|
@@ -53,11 +68,25 @@ function safeJobs(ctx: unknown): JobsLike | undefined {
|
|
|
53
68
|
}
|
|
54
69
|
}
|
|
55
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Read `jobs.start` defensively: the `start` GETTER itself can throw on a
|
|
73
|
+
* hostile/proxied registry, and that read must degrade the same way as a
|
|
74
|
+
* missing registry — never escape `runWithJob`.
|
|
75
|
+
*/
|
|
76
|
+
function safeStart(jobs: JobsLike | undefined): JobsLike['start'] | undefined {
|
|
77
|
+
try {
|
|
78
|
+
return jobs?.start
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/**
|
|
57
85
|
* Run `fn` wrapped in a dsh background job, settling it completed/failed
|
|
58
|
-
* with the execution's outcome
|
|
59
|
-
*
|
|
60
|
-
*
|
|
86
|
+
* with the execution's ACTUAL outcome (a requested cancel never falsifies it
|
|
87
|
+
* — see the header). When the host exposes no job registry (or refuses the
|
|
88
|
+
* start), `fn` runs untouched and `null` is returned — the Job Panel is an
|
|
89
|
+
* enhancement, never a dependency.
|
|
61
90
|
*
|
|
62
91
|
* @param ctx the dsh plugin context (may or may not expose `jobs`).
|
|
63
92
|
* @param kind iterate job kind registered via {@link IterateJobKind}.
|
|
@@ -72,7 +101,8 @@ export async function runWithJob<T>(
|
|
|
72
101
|
fn: () => Promise<T> | T,
|
|
73
102
|
): Promise<{ result: T; jobId: string | null }> {
|
|
74
103
|
const jobs = safeJobs(ctx)
|
|
75
|
-
|
|
104
|
+
const start = safeStart(jobs)
|
|
105
|
+
if (!jobs || !start || typeof start !== 'function') {
|
|
76
106
|
return { result: await fn(), jobId: null }
|
|
77
107
|
}
|
|
78
108
|
|
|
@@ -81,14 +111,35 @@ export async function runWithJob<T>(
|
|
|
81
111
|
settle = resolve
|
|
82
112
|
})
|
|
83
113
|
|
|
114
|
+
// Honest cancel bookkeeping: the request is recorded (first reason wins),
|
|
115
|
+
// but `done` is NOT settled here — `fn()` is still running and the panel
|
|
116
|
+
// must not claim otherwise. The settlement below carries `fn`'s real
|
|
117
|
+
// outcome, annotated with the request.
|
|
118
|
+
let cancelRequested: string | null = null
|
|
119
|
+
const annotate = (detail: string): string =>
|
|
120
|
+
cancelRequested === null
|
|
121
|
+
? detail
|
|
122
|
+
: `${detail} (cancel requested: ${cancelRequested} — the wrapped tool call has no abort channel and ran to completion)`
|
|
123
|
+
|
|
84
124
|
let jobId: string | null = null
|
|
85
125
|
try {
|
|
86
|
-
jobId =
|
|
126
|
+
jobId = start.call(jobs, {
|
|
87
127
|
kind,
|
|
88
128
|
label,
|
|
89
|
-
run: () => ({
|
|
129
|
+
run: (job: JobHandleLike) => ({
|
|
90
130
|
done,
|
|
91
|
-
cancel: () =>
|
|
131
|
+
cancel: (reason?: string) => {
|
|
132
|
+
if (cancelRequested !== null) return // idempotent — first reason wins
|
|
133
|
+
cancelRequested = reason ?? 'cancel requested'
|
|
134
|
+
// Best-effort panel hint while the record is stopping; the registry
|
|
135
|
+
// drops writes it cannot apply, and a throwing update must not
|
|
136
|
+
// propagate out of the registry's kill path.
|
|
137
|
+
try {
|
|
138
|
+
job?.updateProgress?.('cancel requested — waiting for the tool call to finish (no abort channel)')
|
|
139
|
+
} catch {
|
|
140
|
+
/* progress hint is cosmetic */
|
|
141
|
+
}
|
|
142
|
+
},
|
|
92
143
|
}),
|
|
93
144
|
})
|
|
94
145
|
} catch {
|
|
@@ -99,12 +150,12 @@ export async function runWithJob<T>(
|
|
|
99
150
|
|
|
100
151
|
try {
|
|
101
152
|
const result = await fn()
|
|
102
|
-
settle({ status: 'completed', detail: 'done' })
|
|
153
|
+
settle({ status: 'completed', detail: annotate('done') })
|
|
103
154
|
return { result, jobId }
|
|
104
155
|
} catch (error) {
|
|
105
156
|
settle({
|
|
106
157
|
status: 'failed',
|
|
107
|
-
detail: error instanceof Error ? error.message : 'execution failed',
|
|
158
|
+
detail: annotate(error instanceof Error ? error.message : 'execution failed'),
|
|
108
159
|
})
|
|
109
160
|
throw error
|
|
110
161
|
}
|
package/src/live.ts
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* Safety:
|
|
19
19
|
* - Read-only observer: never mutates source files; writes only the NDJSON
|
|
20
20
|
* live file under `.iterate/`.
|
|
21
|
+
* - Privacy switch: when the project's effective config sets
|
|
22
|
+
* `observatory.capture: false`, nothing is persisted (the file stays
|
|
23
|
+
* absent/unchanged). An unreadable config keeps capture ON (defaults).
|
|
21
24
|
* - The live file is byte-capped (rewrite to last N lines when it grows too
|
|
22
25
|
* large) so it can never grow unbounded.
|
|
23
26
|
* - Any capture failure is swallowed (fire-and-forget) so it can never block
|
|
@@ -30,7 +33,7 @@ import { writeTextAtomicAsync } from './atomic-fs.ts'
|
|
|
30
33
|
import { join } from 'node:path'
|
|
31
34
|
import type { Context } from '@deepseek-ai/cordis'
|
|
32
35
|
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
33
|
-
import { resolveProjectRoot } from './config-loader.ts'
|
|
36
|
+
import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
|
|
34
37
|
|
|
35
38
|
/** Keep at most this many live activity entries. */
|
|
36
39
|
export const LIVE_MAX_ENTRIES = 300
|
|
@@ -85,14 +88,40 @@ function enqueueLiveWrite(task: () => Promise<void>): Promise<void> {
|
|
|
85
88
|
return next
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* True when the project's effective config still allows live capture.
|
|
93
|
+
* `observatory.capture: false` opts the project out — nothing is persisted.
|
|
94
|
+
* Failure modes keep TODAY'S behavior: `loadEffectiveConfig` never throws on
|
|
95
|
+
* an unreadable config (it falls back to defaults, capture on), and any
|
|
96
|
+
* unexpected loader error here also degrades to capture ON rather than
|
|
97
|
+
* silently eating activity the operator expected to see.
|
|
98
|
+
*/
|
|
99
|
+
function captureEnabled(projectRoot: string): boolean {
|
|
100
|
+
try {
|
|
101
|
+
return loadEffectiveConfig(projectRoot).config.observatory?.capture !== false
|
|
102
|
+
} catch {
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Append one activity record to the project's live feed (byte-capped).
|
|
109
|
+
* Never rejects: every failure (capture disabled, `.iterate` existing as a
|
|
110
|
+
* regular file, locked/unwritable feed) is swallowed — the header promises
|
|
111
|
+
* this path can never crash or block a tool call (`void appendLive(...)`
|
|
112
|
+
* would otherwise turn a rejection into an unhandled rejection).
|
|
113
|
+
*/
|
|
89
114
|
export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
|
|
90
115
|
return enqueueLiveWrite(async () => {
|
|
91
|
-
const file = liveFilePath(projectRoot)
|
|
92
|
-
const line = JSON.stringify(entry) + '\n'
|
|
93
|
-
await mkdir(join(projectRoot, '.iterate'), { recursive: true })
|
|
94
|
-
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
95
116
|
try {
|
|
117
|
+
// Privacy: capture off → do not touch the feed at all.
|
|
118
|
+
if (!captureEnabled(projectRoot)) return
|
|
119
|
+
const file = liveFilePath(projectRoot)
|
|
120
|
+
const line = JSON.stringify(entry) + '\n'
|
|
121
|
+
// mkdir is INSIDE the guard: `.iterate` may be a regular file (or the
|
|
122
|
+
// dir unwritable) — it must reject neither the caller nor the queue.
|
|
123
|
+
await mkdir(join(projectRoot, '.iterate'), { recursive: true })
|
|
124
|
+
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
96
125
|
const st = await stat(file).catch(() => null)
|
|
97
126
|
if (st && st.size > LIVE_MAX_BYTES) {
|
|
98
127
|
const raw = await readFile(file, 'utf-8')
|
|
@@ -201,10 +230,18 @@ export async function readLive(projectRoot: string): Promise<LiveActivityEntry[]
|
|
|
201
230
|
*/
|
|
202
231
|
export function registerLiveCapture(ctx: Context): void {
|
|
203
232
|
ctx.on('tools/result', (exec: ToolExecution) => {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
233
|
+
// Defensive: a hostile/proxied exec whose getters throw (`agent`,
|
|
234
|
+
// `name`, `arguments`) must not abort the observer — unlike the guarded
|
|
235
|
+
// siblings below, an unguarded throw here would lose the record and
|
|
236
|
+
// surface an error from a read-only hook. Degrade to a no-op.
|
|
237
|
+
try {
|
|
238
|
+
const root = projectRootOf(exec)
|
|
239
|
+
if (!root) return
|
|
240
|
+
const entry = classifyTool(exec.name, exec.arguments, root)
|
|
241
|
+
if (!entry) return
|
|
242
|
+
void appendLive(root, entry)
|
|
243
|
+
} catch {
|
|
244
|
+
// An observer must never throw.
|
|
245
|
+
}
|
|
209
246
|
})
|
|
210
247
|
}
|