Background only the warm-up, and use the framework's own parameter name
The tool backgrounded EVERY long action and exposed an inverted `foreground` flag. dsh-tool-bash does neither: its parameter is `run_in_background`, it defaults to foreground, and the model opts in. That difference is what made a finished job go uncollected. The evidence, from this session's own log: the only slopo job that ever ran was slopo-1, and the agent KILLED it — `kill()` sets `reported = true`, which suppresses the completion notice by design, because a caller who asked to stop something does not want to be told it stopped. The agent then worked around the tool entirely and re-ran slopo through bash, which is where the six notices in that session came from. Nothing was broken; the tool was asking the caller to depend on a best-effort signal. So the query actions return their report directly. Only `prepare` — index plus embed, the genuinely long and watchable step, and the one the user asked to be able to watch — backgrounds by default. Any action can still be backgrounded with `run_in_background: true`. The render now names the collect call explicitly (`job_output` for that id, with `wait: true` when blocked on it) instead of the vaguer "read the job output when it settles", so a caller that is not woken still has an unambiguous next step. I checked the waiter path while I was in there: a timed-out `wait` decrements `waiters` in a `finally`, so it does not leak and does not suppress the notice. That was my first hypothesis and it is wrong — worth recording, since it was the plausible one.
This commit is contained in:
1 parent
2d7a7d1e73
commit
5ff10eaefd
3 files changed
+71
-30
No files matched your search
@@ -148,10 +148,17 @@ Or drive the tool directly:
|
|||||||
| `analyze-one` | One cluster, optionally by `cluster` hash. |
|
| `analyze-one` | One cluster, optionally by `cluster` hash. |
|
||||||
| `update` | Upgrade the slopo CLI. |
|
| `update` | Upgrade the slopo CLI. |
|
||||||
|
|
||||||
The long actions run as **background jobs** by default (pass `foreground: true`
|
`prepare` — the warm-up — starts a **background job** by default, because embedding
|
||||||
to run inline). Embedding takes minutes, and slopo writes its progress to a log
|
takes minutes and slopo writes its progress to a log file rather than to stdout,
|
||||||
file rather than to stdout, so there is nothing to stream — the job handle
|
so there is nothing to stream; the job handle carries the log path instead. The
|
||||||
carries the log path instead.
|
other actions return their report directly, and any of them can be backgrounded
|
||||||
|
with `run_in_background: true` — the same parameter name `dsh-tool-bash` uses.
|
||||||
|
|
||||||
|
That asymmetry is deliberate. A completion notice is best-effort: a **killed** job
|
||||||
|
deliberately produces none, and an agent whose turn has already ended may have the
|
||||||
|
notice injected rather than be woken for it. So the action that returns a report
|
||||||
|
the caller can use immediately is worth more than a job id it has to remember to
|
||||||
|
collect — and only the genuinely long step depends on being watched.
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,16 @@ const SKILL_NAMES = ['slopo-review', 'slopo-analyze-ignore', 'slopo-analyze-one'
|
|||||||
const SKILLS_DIR = fileURLToPath(new URL('./skills/', import.meta.url))
|
const SKILLS_DIR = fileURLToPath(new URL('./skills/', import.meta.url))
|
||||||
|
|
||||||
/** Long-running actions, in the order the tool description advertises them. */
|
/** Long-running actions, in the order the tool description advertises them. */
|
||||||
const BACKGROUND_ACTIONS = new Set(['prepare', 'review', 'analyze', 'analyze-one'])
|
/**
|
||||||
|
* Actions that default to a background job.
|
||||||
|
*
|
||||||
|
* Only the warm-up. `index` plus `embed` is the long, watchable step — the one
|
||||||
|
* the user asked to be able to watch — while a review finds a warm index and
|
||||||
|
* returns its report directly. Named after dsh-tool-bash's `run_in_background`,
|
||||||
|
* because a model's prior is that backgrounding is something it ASKS for rather
|
||||||
|
* than the default.
|
||||||
|
*/
|
||||||
|
const BACKGROUND_BY_DEFAULT = new Set(['prepare'])
|
||||||
|
|
||||||
/** Every configuration field this plugin exposes. */
|
/** Every configuration field this plugin exposes. */
|
||||||
const SETTING_KEYS = [
|
const SETTING_KEYS = [
|
||||||
@@ -386,9 +395,9 @@ export function apply(ctx, config) {
|
|||||||
'dsh-slopo settings; "prepare" runs the local index and then embeds (the slow, possibly costly ' +
|
'dsh-slopo settings; "prepare" runs the local index and then embeds (the slow, possibly costly ' +
|
||||||
'step); "review" finds similar code involving Git changes; "analyze" triages the whole codebase; ' +
|
'step); "review" finds similar code involving Git changes; "analyze" triages the whole codebase; ' +
|
||||||
'"analyze-one" reports a single cluster by hash; "update" upgrades the slopo CLI. Slopo is ' +
|
'"analyze-one" reports a single cluster by hash; "update" upgrades the slopo CLI. Slopo is ' +
|
||||||
'installed and driven as a separate process. Long actions run as background jobs because ' +
|
'installed and driven as a separate process. "prepare" starts a background job by default, because ' +
|
||||||
'embedding a repository takes minutes and slopo writes its progress to a log file rather than ' +
|
'indexing and embedding a repository takes minutes and slopo writes its progress to a log file rather ' +
|
||||||
'to stdout.',
|
'than to stdout; everything else returns its report directly.',
|
||||||
parameters: {
|
parameters: {
|
||||||
action: {
|
action: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -398,9 +407,11 @@ export function apply(ctx, config) {
|
|||||||
},
|
},
|
||||||
base: { type: 'string', description: 'Git ref to compare against for "review". Defaults to HEAD.' },
|
base: { type: 'string', description: 'Git ref to compare against for "review". Defaults to HEAD.' },
|
||||||
cluster: { type: 'string', description: 'Cluster hash for "analyze-one". Omit for the next cluster.' },
|
cluster: { type: 'string', description: 'Cluster hash for "analyze-one". Omit for the next cluster.' },
|
||||||
foreground: {
|
run_in_background: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Run a long action inline instead of as a background job. Defaults to false.',
|
description:
|
||||||
|
'Return a job id instead of the result, for a run long enough to watch. Defaults to true for ' +
|
||||||
|
'"prepare" (the warm-up) and false for everything else, which is quick once the index is warm.',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
@@ -417,7 +428,9 @@ export function apply(ctx, config) {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `Started slopo ${value.action} as background job ${value.jobId}.${log} Read the job output when it settles.`,
|
text:
|
||||||
|
`Started slopo ${value.action} as background job ${value.jobId}.${log} You will be notified when ` +
|
||||||
|
`it settles; collect it with job_output for "${value.jobId}" (add wait: true if you are blocked on it).`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -740,7 +753,12 @@ export function apply(ctx, config) {
|
|||||||
details: { counted, excludes, maxIndexUnits: Number(settings.maxIndexUnits) },
|
details: { counted, excludes, maxIndexUnits: Number(settings.maxIndexUnits) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const wantsBackground = args.foreground !== true && BACKGROUND_ACTIONS.has(args.action)
|
// Background is for the long warm-up, not for every action. A completion
|
||||||
|
// notice is best-effort: a KILLED job deliberately produces none, and an
|
||||||
|
// agent whose turn has ended may have the message injected rather than be
|
||||||
|
// woken for it. A review that returns its report directly is worth more
|
||||||
|
// than a job id the caller has to remember to collect.
|
||||||
|
const wantsBackground = args.run_in_background ?? BACKGROUND_BY_DEFAULT.has(args.action)
|
||||||
const jobs = ctx.get('jobs')
|
const jobs = ctx.get('jobs')
|
||||||
|
|
||||||
if (wantsBackground && jobs !== undefined) {
|
if (wantsBackground && jobs !== undefined) {
|
||||||
|
|||||||
+34
-18
@@ -271,7 +271,7 @@ test('review writes a config the plugin owns and passes the key only in the envi
|
|||||||
embeddingApiKey: 'super-secret',
|
embeddingApiKey: 'super-secret',
|
||||||
})
|
})
|
||||||
const tool = state.tools.get('slopo')
|
const tool = state.tools.get('slopo')
|
||||||
const value = await tool.execute({ action: 'review', foreground: true }, execContext('/tmp/nonexistent-repo-xyz'))
|
const value = await tool.execute({ action: 'review' }, execContext('/tmp/nonexistent-repo-xyz'))
|
||||||
// It will fail — there is no git repo there — but the failure must be a
|
// It will fail — there is no git repo there — but the failure must be a
|
||||||
// reported one, and the config must have been materialised first.
|
// reported one, and the config must have been materialised first.
|
||||||
assert.equal(value.kind, 'foreground')
|
assert.equal(value.kind, 'foreground')
|
||||||
@@ -282,27 +282,43 @@ test('review writes a config the plugin owns and passes the key only in the envi
|
|||||||
assert.doesNotMatch(written, /super-secret/)
|
assert.doesNotMatch(written, /super-secret/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a background-capable profile gets a job handle instead of a blocking call', async () => {
|
test('only the warm-up backgrounds by default; the query actions return directly', async () => {
|
||||||
const started = []
|
const started = []
|
||||||
|
const ran = []
|
||||||
const { state } = harness(
|
const { state } = harness(
|
||||||
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
|
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
|
||||||
{ withJobs: true },
|
{ withJobs: true, shellRun: cannedShell(ran, { indexOutput: 'Indexed 1519 code units from 165 files.' }) },
|
||||||
)
|
)
|
||||||
// ctx.get('jobs') is consulted at call time, not at apply time, so the fake
|
// ctx.get('jobs') is consulted at call time, not at apply time, so the fake
|
||||||
// registry can be installed after the plugin has loaded.
|
// registry can be installed after the plugin has loaded.
|
||||||
state.jobs = {
|
state.jobs = {
|
||||||
start(spec) {
|
start(spec) {
|
||||||
started.push(spec)
|
started.push(spec)
|
||||||
return 'slopo-1'
|
return `slopo-${started.length}`
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const tool = state.tools.get('slopo')
|
const tool = state.tools.get('slopo')
|
||||||
const value = await tool.execute({ action: 'review' }, execContext('/tmp'))
|
|
||||||
assert.equal(value.kind, 'background')
|
// A review returns its report. A completion notice is best-effort — a killed
|
||||||
assert.equal(value.jobId, 'slopo-1')
|
// job produces none, and an ended turn may only get the message injected — so
|
||||||
|
// the query actions must not depend on one.
|
||||||
|
const reviewed = await tool.execute({ action: 'review' }, execContext('/tmp'))
|
||||||
|
assert.equal(reviewed.kind, 'foreground')
|
||||||
|
assert.match(reviewed.report, /cluster 1/)
|
||||||
|
assert.equal(started.length, 0, 'a review must not become a job the caller has to collect')
|
||||||
|
|
||||||
|
// prepare is the long, watchable step, so it does.
|
||||||
|
const prepared = await tool.execute({ action: 'prepare' }, execContext('/tmp'))
|
||||||
|
assert.equal(prepared.kind, 'background')
|
||||||
|
assert.equal(prepared.jobId, 'slopo-1')
|
||||||
assert.equal(started.length, 1)
|
assert.equal(started.length, 1)
|
||||||
assert.equal(started[0].kind, 'slopo')
|
assert.equal(started[0].kind, 'slopo')
|
||||||
assert.equal(typeof started[0].run, 'function')
|
assert.equal(typeof started[0].run, 'function')
|
||||||
|
|
||||||
|
// And either can be asked for explicitly, under the framework's own name.
|
||||||
|
const forced = await tool.execute({ action: 'analyze', run_in_background: true }, execContext('/tmp'))
|
||||||
|
assert.equal(forced.kind, 'background')
|
||||||
|
assert.equal(started.length, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test(
|
test(
|
||||||
@@ -337,10 +353,10 @@ test(
|
|||||||
})
|
})
|
||||||
const tool = state.tools.get('slopo')
|
const tool = state.tools.get('slopo')
|
||||||
|
|
||||||
const prepared = await tool.execute({ action: 'prepare', foreground: true }, execContext(repo))
|
const prepared = await tool.execute({ action: 'prepare', run_in_background: false }, execContext(repo))
|
||||||
assert.equal(prepared.ok, true, JSON.stringify(prepared))
|
assert.equal(prepared.ok, true, JSON.stringify(prepared))
|
||||||
|
|
||||||
const reviewed = await tool.execute({ action: 'review', foreground: true }, execContext(repo))
|
const reviewed = await tool.execute({ action: 'review' }, execContext(repo))
|
||||||
assert.equal(reviewed.ok, true, JSON.stringify(reviewed))
|
assert.equal(reviewed.ok, true, JSON.stringify(reviewed))
|
||||||
assert.match(reviewed.report, /cluster 1/)
|
assert.match(reviewed.report, /cluster 1/)
|
||||||
assert.match(reviewed.report, /\* /)
|
assert.match(reviewed.report, /\* /)
|
||||||
@@ -366,7 +382,7 @@ test('the sandbox policy is resolved for the CALLING SESSION, not the deployment
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const tool = state.tools.get('slopo')
|
const tool = state.tools.get('slopo')
|
||||||
await tool.execute({ action: 'review', foreground: true }, execContext('/tmp/nonexistent-repo-xyz'))
|
await tool.execute({ action: 'review' }, execContext('/tmp/nonexistent-repo-xyz'))
|
||||||
assert.equal(seen.length, 1, 'the policy must be resolved exactly once')
|
assert.equal(seen.length, 1, 'the policy must be resolved exactly once')
|
||||||
assert.ok(seen[0].session !== undefined, 'the calling session must travel with the policy')
|
assert.ok(seen[0].session !== undefined, 'the calling session must travel with the policy')
|
||||||
assert.equal(state.shellRequests.length, 1)
|
assert.equal(state.shellRequests.length, 1)
|
||||||
@@ -420,7 +436,7 @@ test('a workspace-write session with stateDir inside the workspace proceeds', as
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const tool = state.tools.get('slopo')
|
const tool = state.tools.get('slopo')
|
||||||
const value = await tool.execute({ action: 'review', foreground: true }, execContext(workspace))
|
const value = await tool.execute({ action: 'review' }, execContext(workspace))
|
||||||
assert.notEqual(value.errorCode, 'STATE_OUTSIDE_SANDBOX')
|
assert.notEqual(value.errorCode, 'STATE_OUTSIDE_SANDBOX')
|
||||||
assert.ok(state.shellRequests.length > 0, 'it should have attempted the run')
|
assert.ok(state.shellRequests.length > 0, 'it should have attempted the run')
|
||||||
assert.deepEqual(state.shellRequests[0].sandboxPolicy, { mode: 'workspace-write', workspaceRoot: workspace })
|
assert.deepEqual(state.shellRequests[0].sandboxPolicy, { mode: 'workspace-write', workspaceRoot: workspace })
|
||||||
@@ -565,7 +581,7 @@ test('a scope over the budget is refused BEFORE anything is embedded', async ()
|
|||||||
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
|
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
|
||||||
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 113768 code units from 1300 files (0 unchanged, 0 removed).' }) },
|
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 113768 code units from 1300 files (0 unchanged, 0 removed).' }) },
|
||||||
)
|
)
|
||||||
const value = await state.tools.get('slopo').execute({ action: 'review', foreground: true }, execContext('/tmp'))
|
const value = await state.tools.get('slopo').execute({ action: 'review' }, execContext('/tmp'))
|
||||||
|
|
||||||
assert.equal(value.ok, false)
|
assert.equal(value.ok, false)
|
||||||
assert.equal(value.errorCode, 'SCOPE_TOO_LARGE')
|
assert.equal(value.errorCode, 'SCOPE_TOO_LARGE')
|
||||||
@@ -587,7 +603,7 @@ test('a scope under the budget indexes and then runs the action', async () => {
|
|||||||
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
|
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
|
||||||
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 1519 code units from 165 files (0 unchanged, 0 removed).' }) },
|
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 1519 code units from 165 files (0 unchanged, 0 removed).' }) },
|
||||||
)
|
)
|
||||||
const value = await state.tools.get('slopo').execute({ action: 'review', foreground: true }, execContext('/tmp'))
|
const value = await state.tools.get('slopo').execute({ action: 'review' }, execContext('/tmp'))
|
||||||
assert.equal(value.ok, true)
|
assert.equal(value.ok, true)
|
||||||
assert.match(value.report, /cluster 1/)
|
assert.match(value.report, /cluster 1/)
|
||||||
assert.equal(ran.length, 2)
|
assert.equal(ran.length, 2)
|
||||||
@@ -601,7 +617,7 @@ test('raising the budget lets the same scope through', async () => {
|
|||||||
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768, maxIndexUnits: 0 },
|
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768, maxIndexUnits: 0 },
|
||||||
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 113768 code units from 1300 files.' }) },
|
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 113768 code units from 1300 files.' }) },
|
||||||
)
|
)
|
||||||
const value = await state.tools.get('slopo').execute({ action: 'review', foreground: true }, execContext('/tmp'))
|
const value = await state.tools.get('slopo').execute({ action: 'review' }, execContext('/tmp'))
|
||||||
assert.equal(value.ok, true)
|
assert.equal(value.ok, true)
|
||||||
assert.equal(ran.length, 2)
|
assert.equal(ran.length, 2)
|
||||||
})
|
})
|
||||||
@@ -616,7 +632,7 @@ test('the config slopo is handed excludes the defaults, and a user list is added
|
|||||||
},
|
},
|
||||||
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 1519 code units from 165 files.' }) },
|
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 1519 code units from 165 files.' }) },
|
||||||
)
|
)
|
||||||
const value = await state.tools.get('slopo').execute({ action: 'review', foreground: true }, execContext('/tmp'))
|
const value = await state.tools.get('slopo').execute({ action: 'review' }, execContext('/tmp'))
|
||||||
const { readFileSync } = await import('node:fs')
|
const { readFileSync } = await import('node:fs')
|
||||||
const written = readFileSync(value.configPath, 'utf8')
|
const written = readFileSync(value.configPath, 'utf8')
|
||||||
for (const pattern of ['**/node_modules/**', 'ui/**', 'src/controllers/**']) {
|
for (const pattern of ['**/node_modules/**', 'ui/**', 'src/controllers/**']) {
|
||||||
@@ -632,7 +648,7 @@ test('slopo never sees a directory it should not, even when it is asked twice',
|
|||||||
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 1519 code units from 165 files.' }) },
|
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 1519 code units from 165 files.' }) },
|
||||||
)
|
)
|
||||||
const tool = state.tools.get('slopo')
|
const tool = state.tools.get('slopo')
|
||||||
await tool.execute({ action: 'prepare', foreground: true }, execContext('/tmp'))
|
await tool.execute({ action: 'prepare', run_in_background: false }, execContext('/tmp'))
|
||||||
const afterPrepare = ran.length
|
const afterPrepare = ran.length
|
||||||
assert.match(ran[0], /\bindex\b/)
|
assert.match(ran[0], /\bindex\b/)
|
||||||
assert.match(ran[1], /\bembed\b/)
|
assert.match(ran[1], /\bembed\b/)
|
||||||
@@ -680,7 +696,7 @@ test('the gate reads the DATABASE, so a warm index cannot hide a full backlog',
|
|||||||
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768, stateDir },
|
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768, stateDir },
|
||||||
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 0 code units from 0 files (562 unchanged, 0 removed).' }) },
|
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 0 code units from 0 files (562 unchanged, 0 removed).' }) },
|
||||||
)
|
)
|
||||||
const value = await state.tools.get('slopo').execute({ action: 'prepare', foreground: true }, execContext('/tmp'))
|
const value = await state.tools.get('slopo').execute({ action: 'prepare', run_in_background: false }, execContext('/tmp'))
|
||||||
|
|
||||||
assert.equal(value.errorCode, 'SCOPE_TOO_LARGE', 'the summary line said zero, the database said 8,607')
|
assert.equal(value.errorCode, 'SCOPE_TOO_LARGE', 'the summary line said zero, the database said 8,607')
|
||||||
assert.equal(value.details.counted.units, 8607)
|
assert.equal(value.details.counted.units, 8607)
|
||||||
@@ -714,7 +730,7 @@ test('a fully embedded index has no backlog and is never refused', async () => {
|
|||||||
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768, stateDir },
|
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768, stateDir },
|
||||||
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 0 code units from 0 files (562 unchanged, 0 removed).' }) },
|
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 0 code units from 0 files (562 unchanged, 0 removed).' }) },
|
||||||
)
|
)
|
||||||
const value = await state.tools.get('slopo').execute({ action: 'review', foreground: true }, execContext('/tmp'))
|
const value = await state.tools.get('slopo').execute({ action: 'review' }, execContext('/tmp'))
|
||||||
assert.equal(value.ok, true, JSON.stringify(value))
|
assert.equal(value.ok, true, JSON.stringify(value))
|
||||||
assert.equal(ran.length, 2, 'a done index goes straight to the action')
|
assert.equal(ran.length, 2, 'a done index goes straight to the action')
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user